o2o-site-AEO/solution/backend/services/external/gemini_text.py
Mina Choi 9d25ed613e 구조: 사장님(solution)과 내부 운영(admin)을 두 앱으로 가른다
최상단을 프로젝트 단위로 평평하게 둔다 — o2o-negosium 과 같은 규약이고, 이 레포만
다르게 갈 이유가 없다. negodata/{backend,front} 가 프로젝트 안에서 f/b 를 가르는 선례,
lps-admin/ 이 백엔드 없이 프론트만 가진 최상단 폴더의 선례다.

  backend/ frontend/{admin,site,shared}  →  solution/{backend,front,site,shared} + admin/

## 왜

내부 라우트(/local-content, /places/:id/seo)의 이름과 화면 코드가 사장님 번들에
그대로 실려 나가고 있었다. UserRole.DEVELOPER 주석의 "고객사에 존재를 노출하지 않는다"를
번들이 깨고 있었다 — 라우트 가드는 화면을 가리지 번들은 못 가린다.
번들을 갈라 확인했다: 사장님 dist 에서 local-content · /places · SeoAudit 이 전부 0건이다.

그 과정에서 두 곳이 더 새고 있었다.
- AppShell 의 NAV 배열이 내부 메뉴를 하드코딩하고 있었다. 앱을 가른 뒤에도 dist 에
  local-content 가 남아서 찾았다. 메뉴는 이제 앱이 prop 으로 들고 온다.
- EditorHeader·BuilderPage·LoginPage 가 /places 로 링크하고 있었다. 그 화면이 admin 으로
  나갔으니 사장님 앱에서는 404 다. 링크를 걷어내고 LoginPage 기본 도착지는 '/' 로 바꿨다
  (앱마다 홈이 다르고 각 라우터의 '/' 가 이미 그걸 안다).

## admin 에 백엔드를 두지 않았다

내부 화면이 부르는 훅이 전부 router/v1/{place,fact,local,validator} 에 이미 있다.
자체 백엔드를 두면 place·fact·link 를 같은 DB 에 대고 두 번 구현하게 된다.
대가는 solution/backend 가 죽으면 admin 도 멈추는 것 — 내부 도구라 감수한다.

## admin 의 `@` 는 solution/front/src 를 가리킨다

내부 화면이 쓰는 API 클라이언트·UI·수집 배선이 solution 에 한 벌만 있고 그 파일들끼리도
`@/...` 로 서로를 부른다. admin 에서 `@` 를 자기 src 로 잡으면 그 참조가 전부 깨진다
(실측 TS2307 14건). 복제하는 길도 있지만 RecollectPanel 주석이 금지한다 —
"수집 경로를 두 벌 만들면 확정 게이트"가 갈라진다.
admin 자기 파일만 `@admin` 이고, 의존 방향은 admin → solution 한 쪽뿐이다.

admin 이 여는 빌더는 다른 오리진이라 절대 URL + 새 탭이다(admin/src/lib/solutionUrl.ts).
react-router Link 로 두면 admin 안에서 라우트를 찾다 404 다.

## 그 밖

- npm 워크스페이스 루트를 레포 루트로 올렸다(admin 이 solution 밖이라).
- docker-compose 를 255→174줄로 줄이고 admin(:3002) 서비스를 넣었다. ADMIN_BIND 기본값은
  127.0.0.1 — 0.0.0.0 으로 열면 앱을 가른 의미가 없다.
- 발행 호스트를 프론트 .env 에 따로 적지 않는다. compose 가 루트의 SITE_PUBLIC_HOST 를
  VITE_PUBLISH_HOST 로 흘려보낸다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
- nginx/site.conf 를 git 에서 빼고 .example 만 남겼다(.env·*.toml 과 같은 규약).
  compose 가 bind mount 하므로 클론 직후 복사해야 한다 — 없으면 Docker 가 그 자리에
  디렉토리를 만들어 nginx 가 설정 없이 뜬다.
- config.test.toml.example 을 추가했다. 없으면 클론한 사람이 pytest 를 아예 못 돌린다
  (conftest import 단계에서 죽는다). 외부 API 키는 전부 빈값이다 —
  APP_ENV=test 가 .env 를 안 읽는 이유를 여기서 우회하면 안 된다.
- 경로가 한 칸 깊어져 test_schema_ddl(parents[2]→[3]) 과 test_site_theme 을 고쳤다.

검증: front·admin·site 전부 lint 0 / build 0. 백엔드 514 passed.
남은 4건(test_build_publish 3 · test_snapshot 1)은 이 변경 전부터 실패하던 것으로,
손대지 않은 메인 체크아웃에서 같은 4건이 같게 실패하는 것을 확인했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
2026-08-31 15:12:09 +09:00

196 lines
8.3 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,
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)
}]}],
"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