/s/stay 시안의 헤더에는 노래 플레이어가 있는데 그건 손으로 채운 목업이라, 새로 발행한 사이트에는 그 자리가 아예 없었다. 이제 발행이 노래를 만든다. ★ 발행이 노래를 기다린다. BUILD 잡이 스냅샷을 뜨기 **전에** 곡을 만든다 — 먼저 굽고 나중에 붙이면 사장님이 [사이트 열기] 로 보는 첫 화면에 그 기능이 빠져 있다. 값은 발행이 30~40초(실측, 상한 5분) 늦어지는 것이고 그건 감수한다. 단 실패는 발행을 막지 않는다 — 기다리는 것과 막는 것은 다르다. 키가 없거나 작곡이 실패하면 노래 없이 발행되고 사유가 빌드 로그와 place_songs.last_error 에 남는다. ★ 가사를 우리가 쓴다. Suno 에 주제만 던지면 가사를 저쪽이 짓고, 거기엔 이 숙소에 없는 것(수영장·조식)이 섞이는데 검증할 방법이 없다 — 다른 모든 문장은 확인된 fact 로만 쓰면서 노래만 지어낸 말을 싣는 꼴이다. 소개문과 **같은 재료**로 Gemini 가 쓰고 Suno 는 곡만 붙인다. 가사에 ground_check 는 걸지 않는다(정서는 fact 로 대응되지 않는다). 대신 프롬프트가 없는 시설·숫자를 말하지 말라고 못 박는다 — 요금을 노래에 넣으면 틀렸을 때 고쳐 부를 수 없다. ★ Suno 주소는 만료된다. 그 주소를 payload 에 실으면 발행 직후엔 재생되고 몇 주 뒤 조용히 죽는다. mp3 를 받아 보관하고 우리 경로(/s/<slug>/<song_id>.mp3)만 내보낸다. ★ 콜백이 아니라 폴링이다. 우리 백엔드는 Suno 가 닿을 수 있는 주소가 아니라, 콜백을 믿으면 "요청은 성공했는데 결과가 영영 안 옴" 이 된다. - services/external/suno.py: 작곡 요청 + record-info 폴링(10초 간격·상한 5분) + 내려받기 - services/external/gemini_text.generate_song · prompts/song.py: 가사·제목·장르 - services/song_service.py: 재료 → 가사 → 작곡 → 파일 보관. ensure_song 을 빌드가 부른다 - build_service: publish 일 때만 ensure_song 을 먼저 부르고 그 뒤 스냅샷(미리보기는 안 만든다 — 유료) - place_songs 표 신설(init.sql + 0010 마이그레이션 + ORM). 검증 상태가 없다 — 수집한 사실이 아니라 창작물이라 "맞는가" 가 아니라 "만들어졌는가" 만 묻는다(SongStatus) - snapshot·site_payload·shared: READY 인 최신 한 곡만 싣는다. audioUrl 은 우리 경로다 - prerender: songs/ 의 파일을 사이트 디렉토리로 복사하고 **지난 발행의 곡은 치운다** (발행마다 새 곡이라 안 치우면 1MB 짜리가 쌓이고 블롭에도 그대로 올라간다) - site/SongPlayer: 헤더의 작은 플레이어. 자동 재생하지 않고, 곡이 없으면 아무것도 안 그린다. 패널은 hidden 으로 여닫는다 — 조건부 렌더면 닫힌 동안 제목·가사가 DOM 에 없어 크롤러가 못 읽는다(오디오 안의 말은 어차피 못 듣는다) - azure_static: .mp3 content-type 과 immutable 캐시. 블롭 업로드는 발행이 사이트째 한다 — 업로더를 하나 더 두면 같은 컨테이너에 경로·캐시·정리 규칙이 두 벌 생긴다 - compose: solution/site/songs 볼륨. .env.example 에 SUNO_API_KEY·SUNO_CALLBACK_URL 검증: 실제 발행(스테이,머뭄 v15) — 가사 154자 $0.0014 → 작곡 40초 → 1.98MB → 스냅샷(노래 1) → 발행 완료. /s/스테이머뭄-99a887f8 200, mp3 200 audio/mpeg, HTML 에 제목·가사·주소 확인, 지난 곡 404. tsc --noEmit · eslint · vitest 55 passed(신규 4) · 백엔드 관련 188 passed (실패 5건은 전부 컨테이너 환경 유입 — 프론트 소스 부재·SITE_PUBLIC_HOST) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
271 lines
11 KiB
Python
271 lines
11 KiB
Python
"""소개문·메타설명·FAQ 생성 — 겹들을 엮어 결과를 만드는 자리.
|
|
|
|
이 파일이 하는 일은 **엮는 것뿐**이다. 고칠 것이 생기면 해당 겹으로 바로 간다:
|
|
|
|
무엇을 묻는가 services/prompts/copy.py 프롬프트·응답 스키마
|
|
어떻게 부르는가 services/llm/gemini.py HTTP·재시도·토큰·비용
|
|
답을 믿을 것인가 services/grounding/copy.py ground_check · faq_polarity_ok
|
|
무엇을 돌려주는가 여기 근거 모으기 → 호출 → 검증 → 조립
|
|
|
|
한때 이 네 가지가 한 파일 500줄에 뭉쳐 있었다. "FAQ 답이 이상하다" 를 고치러 와도
|
|
어디를 봐야 할지가 파일 안에서 갈리지 않았다.
|
|
"""
|
|
import json
|
|
from dataclasses import dataclass, field
|
|
from typing import Optional
|
|
|
|
import httpx
|
|
|
|
from common.enums import PlaceCategory
|
|
from common.logger import LOG
|
|
from services.grounding.copy import FactInput, faq_polarity_ok, ground_check
|
|
from services.llm.gemini import (
|
|
DEFAULT_MODEL as DEFAULT_TEXT_MODEL,
|
|
GeminiError,
|
|
GeminiInvalidOutput,
|
|
GeminiNotConfigured,
|
|
Usage,
|
|
call,
|
|
extract_text,
|
|
is_configured,
|
|
price,
|
|
read_usage,
|
|
)
|
|
from services.prompts.copy import RESPONSE_SCHEMA, build_prompt
|
|
|
|
|
|
@dataclass
|
|
class GeneratedFaq:
|
|
question: str
|
|
answer: str
|
|
fact_keys: list[str] = field(default_factory=list)
|
|
|
|
|
|
@dataclass
|
|
class GeneratedCopy:
|
|
"""생성 결과. 검증을 통과한 것만 담긴다.
|
|
|
|
rejected 에는 (버린 내용, 사유) 가 들어간다 — 조용히 버리지 않는다.
|
|
운영자가 "왜 소개문이 안 나왔나" 를 이 목록으로 읽는다."""
|
|
|
|
intro: Optional[str] = None
|
|
intro_fact_keys: list[str] = field(default_factory=list)
|
|
meta_description: Optional[str] = None
|
|
faqs: list[GeneratedFaq] = field(default_factory=list)
|
|
rejected: list[tuple[str, str]] = field(default_factory=list)
|
|
|
|
|
|
def _unit_facts(unit_summaries: Optional[list[dict]]) -> list[FactInput]:
|
|
"""객실·프로그램 요약을 근거 fact 로 펼친다.
|
|
|
|
{"name": "A동", "facts": {"max_capacity": "4"}} → FactInput("A동:max_capacity", …)
|
|
이렇게 해야 "최대 4명" 같은 문장이 근거 있는 것으로 통과한다.
|
|
|
|
★ `labels` 가 함께 오면 스키마 라벨·단위를 쓴다({key: {"label","unit"}}).
|
|
이 목록은 프롬프트에도 그대로 실리므로, 라벨이 없으면 모델이 'weekday_price' 라는
|
|
날 key 를 보고 글을 쓴다 — "weekday_price는 20000입니다" 같은 문장이 나온다.
|
|
없으면 지금까지처럼 key 를 라벨 자리에 둔다(호출측이 스키마를 모를 수 있다).
|
|
"""
|
|
out: list[FactInput] = []
|
|
for unit in unit_summaries or []:
|
|
name = str(unit.get("name") or "").strip()
|
|
labels = unit.get("labels") or {}
|
|
if name:
|
|
out.append(FactInput(key=f"unit:{name}", label="객실·프로그램명", value=name))
|
|
for key, value in (unit.get("facts") or {}).items():
|
|
if value is None or str(value).strip() == "":
|
|
continue
|
|
spec = labels.get(key) or {}
|
|
out.append(FactInput(
|
|
key=f"{name}:{key}" if name else key,
|
|
label=spec.get("label") or key,
|
|
value=str(value),
|
|
unit=spec.get("unit"),
|
|
))
|
|
return out
|
|
|
|
|
|
def _valid_keys(claimed: list, allowed: set[str]) -> list[str]:
|
|
"""모델이 적어준 근거 key 중 실제로 존재하는 것만 남긴다(없는 key 를 지어내기도 한다)."""
|
|
return [k for k in (claimed or []) if isinstance(k, str) and k in allowed]
|
|
|
|
|
|
async def generate_copy(
|
|
place_name: str,
|
|
category: PlaceCategory,
|
|
facts: list[FactInput],
|
|
*,
|
|
unit_summaries: Optional[list[dict]] = None,
|
|
records: Optional[list[str]] = None,
|
|
max_faqs: int = 8,
|
|
model: str = DEFAULT_TEXT_MODEL,
|
|
max_retries: int = 2,
|
|
client: Optional[httpx.AsyncClient] = None,
|
|
) -> GeneratedCopy:
|
|
"""확보된 fact 만으로 소개문·메타설명·FAQ 를 만든다.
|
|
|
|
★ facts 가 비면 **API 를 호출하지 않고** 빈 결과를 돌려준다 —
|
|
근거 없이 문장을 쓰면 그게 곧 환각이다.
|
|
★ 생성 결과는 전부 ground_check 를 통과한 것만 담긴다. 통과 못 한 항목은 rejected 로 간다.
|
|
★ 생성 대상 필드는 업종 스키마의 allow_llm=True 인 것뿐이다(호출측이 필터링해서 넘긴다).
|
|
"""
|
|
if not is_configured():
|
|
raise GeminiNotConfigured("GEMINI_API_KEY 가 설정되지 않았다")
|
|
# ★ 사업장 fact 가 없어도 객실·메뉴 근거가 있으면 쓴다. 요금표만 있는 모텔이 그 경우다 —
|
|
# "대실 20,000원" 은 근거 있는 사실이고, 손님이 가장 먼저 묻는 것이기도 하다.
|
|
unit_grounding = _unit_facts(unit_summaries)
|
|
if not facts and not unit_grounding:
|
|
LOG.i(f"[gemini-text] '{place_name}' 근거 fact 0건 — 생성하지 않는다(호출 없음)")
|
|
return GeneratedCopy(rejected=[("(전체)", "근거 fact 가 없다 — 생성하지 않았다")])
|
|
|
|
# 검증에 쓸 근거 = 넘겨받은 fact + 객실 요약 + 상호명(상호에 숫자가 있어도 근거로 본다)
|
|
grounding = list(facts) + unit_grounding
|
|
grounding.append(FactInput(key="place_name", label="상호명", value=place_name))
|
|
allowed_keys = {f.key for f in facts} | {f.key for f in grounding}
|
|
|
|
body = {
|
|
"contents": [{"role": "user", "parts": [{
|
|
"text": build_prompt(place_name, category, facts, max_faqs, unit_grounding, records)
|
|
}]}],
|
|
"generationConfig": {
|
|
"responseMimeType": "application/json",
|
|
"responseSchema": RESPONSE_SCHEMA,
|
|
"temperature": 0.2,
|
|
},
|
|
}
|
|
|
|
owns_client = client is None
|
|
client = client or httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0))
|
|
try:
|
|
payload = await call(client, model, body, max_retries)
|
|
parsed = json.loads(extract_text(payload))
|
|
except json.JSONDecodeError as ex:
|
|
raise GeminiInvalidOutput(f"구조화 출력 파싱 실패: {ex}") from ex
|
|
finally:
|
|
if owns_client:
|
|
await client.aclose()
|
|
|
|
usage = read_usage(payload)
|
|
|
|
result = GeneratedCopy()
|
|
|
|
# ── 소개문 ──
|
|
intro = (parsed.get("intro") or "").strip()
|
|
if intro:
|
|
ok, reasons = ground_check(intro, grounding)
|
|
if ok:
|
|
result.intro = intro
|
|
result.intro_fact_keys = _valid_keys(parsed.get("intro_fact_keys"), allowed_keys)
|
|
else:
|
|
result.rejected.append((intro, " / ".join(reasons)))
|
|
|
|
# ── 메타 설명 ──
|
|
meta_desc = (parsed.get("meta_description") or "").strip()
|
|
if meta_desc:
|
|
ok, reasons = ground_check(meta_desc, grounding)
|
|
if ok:
|
|
result.meta_description = meta_desc
|
|
else:
|
|
result.rejected.append((meta_desc, " / ".join(reasons)))
|
|
|
|
# ── FAQ ── 항목마다 따로 검사한다. 하나가 걸려도 나머지는 산다.
|
|
for item in (parsed.get("faqs") or [])[:max_faqs]:
|
|
question = (item.get("question") or "").strip()
|
|
answer = (item.get("answer") or "").strip()
|
|
if not question or not answer:
|
|
continue
|
|
keys = _valid_keys(item.get("fact_keys"), allowed_keys)
|
|
if not keys:
|
|
# ★ 근거를 못 대는 FAQ 는 버린다 — 사실인지 확인할 방법이 없다.
|
|
result.rejected.append((question, "근거 fact_keys 가 없다"))
|
|
continue
|
|
ok, reasons = ground_check(f"{question} {answer}", grounding)
|
|
# 질문은 주장이 아니라 값-반대 판정에서 빠진다. 그 빈틈은 답변 쪽에서 따로 막는다.
|
|
polar_ok, polar_reasons = faq_polarity_ok(question, answer, grounding)
|
|
if not ok or not polar_ok:
|
|
result.rejected.append((question, " / ".join(reasons + polar_reasons)))
|
|
continue
|
|
result.faqs.append(GeneratedFaq(question=question, answer=answer, fact_keys=keys))
|
|
|
|
LOG.i(
|
|
f"[gemini-text] '{place_name}' 생성 — 소개문 {'O' if result.intro else 'X'} · "
|
|
f"메타 {'O' if result.meta_description else 'X'} · FAQ {len(result.faqs)}건 · "
|
|
f"반려 {len(result.rejected)}건 · model={model} · "
|
|
f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${price(model, usage)}"
|
|
)
|
|
return result
|
|
|
|
|
|
@dataclass
|
|
class GeneratedSong:
|
|
"""가사 생성 결과. 곡은 여기서 만들지 않는다 — 작곡은 services/external/suno 다."""
|
|
|
|
title: str
|
|
lyrics: str
|
|
style: str
|
|
|
|
|
|
async def generate_song(
|
|
place_name: str,
|
|
category: PlaceCategory,
|
|
*,
|
|
region: str,
|
|
grounding: list[str],
|
|
intro: str = "",
|
|
model: str = DEFAULT_TEXT_MODEL,
|
|
max_retries: int = 2,
|
|
client: Optional[httpx.AsyncClient] = None,
|
|
) -> GeneratedSong:
|
|
"""이 업소의 노래 가사를 쓴다.
|
|
|
|
★ `ground_check` 를 걸지 않는다. 가사는 사실 진술이 아니라 정서라 문장 단위로 근거를
|
|
맞추면 전부 반려된다("밤이 깊어도 불이 켜져 있다" 에 대응하는 fact 는 없다).
|
|
대신 프롬프트가 **없는 시설·숫자를 말하지 말라**고 못 박는다(services/prompts/song 머리주석).
|
|
★ 재료가 하나도 없으면 부르지 않는다 — 소개문과 같은 규칙이다. 상호와 지역만으로 쓴 노래는
|
|
어느 숙소에 붙여도 말이 되는 노래이고, 그건 이 기능이 하려던 일이 아니다.
|
|
"""
|
|
if not is_configured():
|
|
raise GeminiNotConfigured("GEMINI_API_KEY 가 설정되지 않았다")
|
|
if not grounding and not (intro or "").strip():
|
|
raise GeminiInvalidOutput("가사를 쓸 재료가 없다 — 확인된 fact 도 소개문도 없다")
|
|
|
|
from common.category_schema import get_schema
|
|
from services.prompts.song import RESPONSE_SCHEMA as SONG_SCHEMA, build_prompt as build_song_prompt
|
|
|
|
body = {
|
|
"contents": [{"role": "user", "parts": [{
|
|
"text": build_song_prompt(place_name, get_schema(category).label, region, grounding, intro)
|
|
}]}],
|
|
"generationConfig": {
|
|
"responseMimeType": "application/json",
|
|
"responseSchema": SONG_SCHEMA,
|
|
# 소개문(0.2)보다 높다 — 노래는 정확해야 하는 글이 아니라 흥얼거릴 글이다.
|
|
"temperature": 0.9,
|
|
},
|
|
}
|
|
|
|
owns_client = client is None
|
|
client = client or httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0))
|
|
try:
|
|
payload = await call(client, model, body, max_retries)
|
|
parsed = json.loads(extract_text(payload))
|
|
except json.JSONDecodeError as ex:
|
|
raise GeminiInvalidOutput(f"가사 파싱 실패: {ex}") from ex
|
|
finally:
|
|
if owns_client:
|
|
await client.aclose()
|
|
|
|
title = (parsed.get("title") or "").strip()
|
|
lyrics = (parsed.get("lyrics") or "").strip()
|
|
style = (parsed.get("style") or "").strip()
|
|
if not lyrics:
|
|
raise GeminiInvalidOutput("가사가 비어 있다")
|
|
|
|
usage = read_usage(payload)
|
|
LOG.i(
|
|
f"[gemini-text] '{place_name}' 가사 — '{title}' ({style}) · {len(lyrics)}자 · "
|
|
f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${price(model, usage)}"
|
|
)
|
|
# 제목이 비면 상호를 쓴다 — 빈 제목은 플레이어에서 빈 줄로 보인다.
|
|
return GeneratedSong(title=title or place_name, lyrics=lyrics, style=style or "acoustic ballad")
|