o2o-site-AEO/solution/backend/services/external/gemini_text.py
hbyang 0e0f2cf038 [feat] solution,postgres-init,docs: SNS 게재 — 사장님이 누르면 쓰고, 승인받아, 사장님 계정으로 올린다
발행한 사이트로 사람을 데려올 경로가 제품 안에 없었다. IndexNow 통보와 사이트맵뿐이고 그건
검색엔진이 언제 읽을지 우리가 모른다. 이제 사장님이 [Threads에 알리기] 를 누르면 확인된 fact 로
짧은 글을 쓰고, 승인을 받아 사장님 개인 계정으로 올린다. 올린 글은 발행본 맨 아래에도 실린다.

★ 이 레포가 처음으로 ①외부에 쓰기를 하고 ②남의 계정 자격증명을 보관하고 ③되돌릴 수 없는
  행위를 한다. 아래 결정이 전부 여기서 나왔다.

승인을 다시 둔다 — 7절("승인 없이 나간다")의 예외다(DECISIONS 7-1). 기준은 문장의 참/거짓이
아니라 명의(사장님 계정의 발언) · 회수 가능성(없다) · 무엇이 주로 틀리나(문장이 아니라 링크 —
`_publish_target` 이 계산하므로 앞 게이트가 못 본다)다. 7절의 함정은 구조로 막았다:
시작이 사장님 클릭이라 "안 눌러서 영영 안 나감" 이 생기지 않고, 승인 경로가 둘(화면·알림톡)이며,
미승인은 EXPIRED 로 화면에 보이게 남는다.

★ 게시는 `domain` 이 확정된 사이트에만. 비면 슬러그가 상호명에서 파생돼(`_publish_target`)
  상호를 고치는 순간 주소가 바뀌고, 이미 올라간 글의 링크는 404 가 된다 — 그 글은 수정할 수 없다.
★ 승인은 GET 이 아니라 POST. 메신저 링크 미리보기·백신·프리페치가 사람이 누르기 전에 URL 을
  연다. 일회성은 토큰이 아니라 `status='PENDING_APPROVAL'` 조건이 붙은 단일 UPDATE 가 보장한다.
★ 사진은 올리지 않는다 — 1-2 의 격리("나중에 필터로 뺀다")가 SNS 에서는 구조적으로 불가능하다.
  필터가 아니라 첨부 코드를 아예 만들지 않았다.
★ 게시는 기본으로 꺼져 있다(`SOCIAL_POSTING_ENABLED=0`). 플랫폼 계약과 1-4(해지 시 처리)
  결론을 확인한 뒤 사람이 연다 — 1-4 가 이 기능의 전제조건이 됐다.

플랫폼은 스레드다. X 는 URL 이 든 글에 요청당 $0.20 이 안내돼 있어 "계정 단위 고정비" 라는
처음 가정이 틀렸다(사이트마다 나가는 변동비다). 어댑터 경계는 두되 X 어댑터는 넣지 않았다.

- place_social_posts · owner_social_accounts 신설(init.sql + 0012·0013). 승인 대기는 잡이 아니라
  행의 상태다 — 잡으로 매달면 lease 만료로 DEAD 가 된다
- services/social_service · social_account_service · notify_service · external/{threads,alimtalk,social}
- router/v1/social — GET 은 상태를 바꾸지 않고, POST 가 링크·계정을 재검사한 뒤 CAS 한다
- 빌더 SocialPanel(발행 완료 화면) + 무인증 승인 페이지 `/approve/:postId`
- 발행본 SocialPostsSection — 정적 카드 + 원문 링크. 위젯·임베드 없음. 고유 콘텐츠 계수에서 제외
- nginx: `/approve/` 는 no-referrer · no-store · noindex + 액세스 로그 끔

밟은 함정 둘
- ORM 기본값에 쉼표가 딸려 들어갔다: `text("'[]',")` → `DEFAULT '[]', NOT NULL` 로 나가
  CREATE TABLE 이 통째로 실패. 운영 DB 는 init.sql 로 만들어져 안 드러나고 ORM 이 스키마를
  만드는 테스트 DB 에서만 터진다 — 09-10 의 `now()` 기본값 사고와 같은 자리다
- 승인 스윕이 1분 주기라 쓰기 커넥션을 계속 집어 들었다 → 5분. 이 스윕은 만료 표시와 중단 정리뿐이라
  분 단위 정밀도가 필요 없다

검증: 백엔드 645 passed / 5 failed(전부 환경 — 프론트 소스 부재·레이트리밋).
★ 테스트에 실제 API 키가 새면 BUILD 잡이 Suno·Perplexity 를 진짜로 부른다(실측: 한 파일 12분 →
키를 비우면 10초). 키를 비운 상태가 정상 실행 조건이다.
에디터 목록 대조(test_site_theme) 22건 통과 · tsc·eslint 통과 · vitest 62 passed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 15:46:44 +09:00

312 lines
14 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")
async def generate_social_post(place_name, facts, link_url, provider=2, *, client=None):
"""실제 게시 문자열을 검증한다. 초과·근거 실패 시 다시 받고 문장을 자르지 않는다."""
from services.prompts import social
from services.external.social import adapter, weighted_length, URL
import unicodedata
if not facts:
raise GeminiInvalidOutput('NO_GROUNDED_FACTS')
if not is_configured():
raise GeminiNotConfigured('GEMINI_NOT_CONFIGURED')
limit = adapter(provider).weighted_limit()
allowed = {f.key for f in facts}
feedback = ''
owns = client is None
client = client or httpx.AsyncClient(timeout=45)
try:
for _ in range(3):
request = {'contents': [{'role': 'user', 'parts': [{'text': social.build_prompt(
place_name, facts, limit - weighted_length('\n\n' + link_url, provider), feedback)}]}],
'generationConfig': {'responseMimeType': 'application/json',
'responseSchema': social.RESPONSE_SCHEMA, 'temperature': 0.2,
'maxOutputTokens': 1024}}
result = await call(client, DEFAULT_TEXT_MODEL, request, max_retries=0)
try:
parsed = json.loads(extract_text(result))
body = unicodedata.normalize('NFC', parsed['body'].strip())
keys = parsed['fact_keys']
text = body + '\n\n' + link_url
ok, _ = ground_check(body, facts + [FactInput(key='name', label='상호명', value=place_name)])
if (body and isinstance(keys, list) and keys and all(k in allowed for k in keys)
and ok and not URL.search(body) and weighted_length(text, provider) <= limit):
return text
except (ValueError, KeyError, TypeError):
pass
feedback = '이전 응답은 길이 또는 근거 검증에 실패했다. 더 짧게, 제공된 사실만으로 다시 써라.'
raise GeminiInvalidOutput('SOCIAL_INVALID_OUTPUT')
finally:
if owns:
await client.aclose()