최상단을 프로젝트 단위로 평평하게 둔다 — 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
196 lines
8.3 KiB
Python
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
|