o2o-site-AEO/backend/services/grounding/copy.py
Mina Choi 6784e59ca5 최초 커밋 — 기존 코드 전체 + 문서 체계 신설
git 저장소가 없어 히스토리·협업 기반이 아예 없던 상태를 연다.
함께 문서를 재편했다. 그동안 문서가 있어도 "이 제품이 뭘 푸는가"와
"어떻게 도는가"를 담은 문서가 없어서, 목표 문장이 backend/frontend
README 두 곳에 복붙돼 있었다 — 상위 문서가 없어 아래로 샌 것이다.

신설
  README.md               레포 진입점 + 문서 지도 + 문서 규칙 4가지
  AGENTS.md               에이전트·신규 합류자용 함정 목록과 규약
                          (CLAUDE.md 는 여기로 걸린 심볼릭 링크)
  docs/PRODUCT.md         제품 정의 — 문제·사용자·원칙·**non-goals**·성공 기준
  docs/ARCHITECTURE.md    payload 경계·발행 파이프라인·서빙 결정·앱 분리 설계

이동
  backend/docs/DECISIONS.md → docs/DECISIONS.md
    백엔드만의 결정이 아니다. 게다가 코드 주석 ~25곳이 이미
    `docs/DECISIONS.md` 로 적고 있어 레포 루트 기준으로는 그게 맞다.

갱신
  docs/DEPLOY.md          서빙 결정 반영 — nginx 정적 서빙이 지금 경로(3절),
                          Azure 는 나중에 켤 때(4절)로 분리
  docs/ARCHITECTURE.md    사이트 = 한 장(2026-08-31) 구조 반영
  docs/COLLECTION_SEO_AEO_FLOW.md
                          robots.txt·sitemap.xml 은 오리진 루트에만 굽는다는 점 명시
  frontend/site/scripts/prerender.ts
                          헤더 주석의 렌더 보고서 경로가 실제(422줄)와 달라 수정

.gitignore
  ★ CLAUDE.md 를 더 이상 무시하지 않는다. 에이전트 지침은 팀과 모든
    에이전트가 공유하는 규약이라 커밋해야 한다 — 무시하면 클론한 사람이
    "배포 후 republish_all.py 필수" 같은 함정을 전달받지 못한다.
    개인용 오버라이드는 ~/.claude/CLAUDE.md 에 둔다.
2026-08-31 13:57:59 +09:00

256 lines
11 KiB
Python

"""소개문·FAQ 의 근거 검증 — LLM 이 지어낸 문장을 걸러내는 규칙.
★ 왜 코드로 또 검사하나
프롬프트에 "주어진 사실만 써라" 를 몇 번이나 못박아도 모델은 종종 어긴다.
프롬프트는 1차 방어일 뿐이고, 실제로 손님에게 나갈 문장을 지키는 건 여기다.
틀린 요금·없는 시설이 사이트에 실리면 그건 그대로 예약 클레임이 된다.
★ 왜 호출 코드에서 떼어냈나
한때 이 규칙이 Gemini HTTP 호출과 같은 파일에 500줄로 뭉쳐 있었다.
"FAQ 가 멀쩡한데 반려된다" 를 고치려면 호출·재시도 코드를 헤치고 들어가야 했다.
검증 규칙은 외부 호출이 전혀 없는 순수 함수라 여기서 따로 읽고 따로 테스트한다.
프롬프트를 고치려면 services/prompts/copy.py, 호출을 고치려면 services/llm/gemini.py 다.
"""
import re
from dataclasses import dataclass
from typing import Optional
# ── 시설 어휘 사전 ────────────────────────────────────────────────────────
# 문장에 이 낱말이 나오면 대응 fact key 중 하나가 **반드시** 있어야 한다.
# 어느 업종 스키마에도 없는 key(pool/sauna/elevator …)로 매핑된 낱말은 언제나 반려된다 —
# 그게 바로 "fact 에 없는 시설을 지어낸" 경우다.
_FACILITY_VOCAB: dict[str, tuple[str, ...]] = {
# 실제로 스키마에 있는 것
"바비큐": ("bbq_available", "bbq_fee"),
"BBQ": ("bbq_available", "bbq_fee"),
"주차": ("parking", "parking_capacity", "parking_free_hours"),
"와이파이": ("wifi",),
"wifi": ("wifi",),
"Wi-Fi": ("wifi",),
"인터넷": ("wifi",),
"조식": ("breakfast",),
"아침 식사": ("breakfast",),
"반려동물": ("pet_allowed",),
"애완": ("pet_allowed",),
"취사": ("cooking_allowed", "has_kitchen"),
"조리": ("cooking_allowed", "has_kitchen"),
"주방": ("has_kitchen", "cooking_allowed"),
"흡연": ("smoking",),
"픽업": ("pickup_service",),
"유아용품": ("baby_amenities",),
"에어컨": ("has_aircon",),
"냉방": ("has_aircon",),
"테라스": ("terrace",),
"야외석": ("terrace",),
"콘센트": ("power_outlet",),
"배달": ("delivery",),
"포장": ("takeout",),
"테이크아웃": ("takeout",),
"휠체어": ("wheelchair_accessible",),
"물품보관함": ("locker_available",),
"락커": ("locker_available",),
"샤워": ("shower_available",),
"콜키지": ("corkage",),
"단체석": ("group_seat_max",),
"노키즈": ("kids_allowed",),
"예약 필수": ("reservation_required",),
# 어느 스키마에도 없는 시설 — 언급되면 무조건 근거 없음
"수영장": ("pool",),
"온수풀": ("pool",),
"스파": ("spa",),
"사우나": ("sauna",),
"찜질": ("sauna",),
"엘리베이터": ("elevator",),
"헬스": ("gym",),
"피트니스": ("gym",),
"노래방": ("karaoke",),
"당구": ("billiard",),
"족구": ("sports_court",),
"카라반": ("caravan",),
"글램핑": ("glamping",),
"루프탑": ("rooftop",),
"정원": ("garden",),
"오션뷰": ("view",),
"조식뷔페": ("breakfast",),
}
# 값이 '아니다'를 뜻하는 표기. 이 값인데 문장이 긍정으로 쓰면 반려한다.
_FALSY = {"false", "0", "no", "n", "불가", "불가능", "없음", "미제공", "제공안함", "안됨", "없습니다"}
# 부정 표현. 시설 낱말 뒤 이 범위 안에 있으면 '없다'고 말한 것으로 본다.
_NEGATION = ("불가", "안 됩", "안됩", "안 돼", "안돼", "없습니다", "없음", "않습니다", "않으", "제한", "금지", "미제공", "어렵습니다")
_NEGATION_WINDOW = 24
# 문장 분리용. 의문문은 '주장' 이 아니라서 값 반대 판정(3번)에서 제외한다 —
# "반려동물 동반이 가능한가요?" 는 사실을 주장하는 게 아니라 묻는 것이다(실호출에서 오탐 확인).
_SENT_SPLIT = re.compile(r"(?<=[.!?。])\s*|\n+")
# 홍보성·과장 표현. fact 값에 그대로 들어있지 않으면 반려한다.
# (객실명이 "프리미엄 스위트" 라면 fact 값에 있으므로 통과한다.)
_PROMOTIONAL = (
"최고", "최상", "최적", "최대한", "완벽", "국내 최", "업계 1위", "1위", "명품", "럭셔리",
"최고급", "독보적", "비교불가", "단연", "손꼽히는", "자랑하는", "환상적", "황홀", "잊지 못할",
"특별한 추억", "아름다운", "쾌적한", "넓고", "저렴한", "합리적인 가격",
)
# 숫자 뒤 한글 자릿수. "2만원" 을 20000 으로도 본다.
_SCALE = {"만": 10_000, "천": 1_000, "억": 100_000_000}
_NUM_RE = re.compile(r"(\d[\d,]*)\s*([만천억])?")
@dataclass
class FactInput:
"""생성 근거로 넘기는 확보된 fact 1건. 검증도 이 목록으로만 한다."""
key: str
label: str
value: str
unit: Optional[str] = None
# ── 근거 검증 ─────────────────────────────────────────────────────────────
def _number_occurrences(text: str) -> list[tuple[str, set[str]]]:
"""문자열의 숫자를 **등장 단위로** 뽑는다. (표기, 같은 값으로 볼 수 있는 후보들)
"2만원" → ("2만", {"2", "20000"}) ← 둘 중 하나만 fact 에 있으면 근거가 있는 것이다
"20,000" → ("20,000", {"20000"})
"15:00" → ("15", {"15"}), ("00", {"0"})
등장 단위로 묶는 이유: "2만원" 을 {"2","20000"} 로 평평하게 펴면
fact 에 20000 이 있어도 "2" 가 근거 없다며 반려된다."""
out: list[tuple[str, set[str]]] = []
for raw, scale in _NUM_RE.findall(text or ""):
digits = raw.replace(",", "")
if not digits:
continue
alts = {digits.lstrip("0") or "0"}
if scale:
try:
alts.add(str(int(digits) * _SCALE[scale]))
except ValueError:
pass
out.append((raw + (scale or ""), alts))
return out
def _fact_number_tokens(facts: list[FactInput]) -> set[str]:
"""fact 값에 등장하는 모든 숫자 표현. 여기서는 평평하게 펴도 된다(대조 대상이라서)."""
tokens: set[str] = set()
for f in facts:
for _raw, alts in _number_occurrences(f.value or ""):
tokens |= alts
return tokens
def _fact_key_map(facts: list[FactInput]) -> dict[str, FactInput]:
return {f.key: f for f in facts}
def _is_falsy(value: str) -> bool:
v = (value or "").strip().lower()
return v in _FALSY
def _negated_near(text: str, pos: int, word_len: int) -> bool:
"""시설 낱말 바로 뒤에 부정 표현이 있는지 — '반려동물 동반 불가' 를 긍정 주장으로 보지 않게."""
window = text[pos + word_len: pos + word_len + _NEGATION_WINDOW]
return any(n in window for n in _NEGATION)
def _is_question_at(text: str, pos: int) -> bool:
"""그 위치가 의문문 안인지. 의문문은 사실을 주장하지 않는다.
FAQ 의 질문("반려동물 동반이 가능한가요?")을 긍정 주장으로 오해하면
멀쩡한 문답이 통째로 버려진다 — 실호출에서 실제로 겪은 오탐이다."""
start = 0
for match in _SENT_SPLIT.finditer(text):
if match.start() > pos:
break
start = match.end()
end = len(text)
for match in _SENT_SPLIT.finditer(text, pos):
end = match.start()
break
sentence = text[start:end]
return sentence.rstrip().endswith("?") or "나요" in sentence or "까요" in sentence
def faq_polarity_ok(question: str, answer: str, facts: list[FactInput]) -> tuple[bool, list[str]]:
"""FAQ 전용 — 질문이 '불가한 시설' 을 물었으면 **답변이 반드시 부정해야** 한다.
질문은 주장이 아니라 ground_check 의 값-반대 판정에서 빠진다. 그 빈틈을 여기서 막는다:
pet_allowed=false 인데 "가능한가요?" 라 묻고 "네, 가능합니다" 라 답하면 잡아야 한다."""
key_map = _fact_key_map(facts)
reasons: list[str] = []
lowered_q = (question or "").lower()
for word, keys in _FACILITY_VOCAB.items():
if lowered_q.find(word.lower()) < 0:
continue
falsy = [k for k in keys if k in key_map and _is_falsy(key_map[k].value)]
if not falsy:
continue
if not any(n in (answer or "") for n in _NEGATION):
reasons.append(f"'{word}' 은 {falsy[0]}='{key_map[falsy[0]].value}' 인데 답변이 부정하지 않는다")
return (not reasons), reasons
def ground_check(text: str, facts: list[FactInput]) -> tuple[bool, list[str]]:
"""생성된 문장이 fact 로 뒷받침되는지 검사한다.
★ 이게 이 모듈의 핵심이다. 프롬프트로 "지어내지 마라" 라고 해도 모델은 종종 지어낸다.
그래서 결과를 코드로 검증하고, 통과 못 하면 문장을 버린다.
잡아내는 것:
1. 문장에 나온 숫자·시각·금액이 fact 값에 없다 → 근거 없는 수치
2. fact 에 없는 시설을 언급했다 (수영장·사우나·엘리베이터 …)
3. fact 값이 '불가/없음' 인데 긍정문으로 썼다 (pet_allowed=false 인데 "반려동물 동반 가능")
4. 홍보성 과장 표현 ("국내 최고의", "완벽한")
반환: (통과 여부, 사유 목록)
"""
if not (text or "").strip():
return False, ["빈 문장"]
reasons: list[str] = []
key_map = _fact_key_map(facts)
fact_values = " ".join((f.value or "") for f in facts)
fact_numbers = _fact_number_tokens(facts)
# 1. 근거 없는 수치 — 등장마다 후보 중 하나라도 fact 에 있으면 통과
seen_bad: set[str] = set()
for raw, alts in _number_occurrences(text):
if not (alts & fact_numbers) and raw not in seen_bad:
seen_bad.add(raw)
reasons.append(f"근거 없는 수치 '{raw}' — fact 값에 없다")
# 2·3. 시설 언급
lowered = text.lower()
for word, keys in _FACILITY_VOCAB.items():
pos = lowered.find(word.lower())
if pos < 0:
continue
if word in fact_values: # fact 값에 그 낱말이 그대로 있으면 근거가 있다
continue
present = [k for k in keys if k in key_map]
if not present:
reasons.append(f"근거 없는 시설 언급 '{word}' — 해당 fact 가 없다")
continue
# 값이 '아니다'인데 부정 없이 **주장**했다 → 반대로 말한 것.
# 의문문은 주장이 아니므로 제외한다(FAQ 질문이 통째로 버려지는 것을 막는다).
if (
all(_is_falsy(key_map[k].value) for k in present)
and not _negated_near(text, pos, len(word))
and not _is_question_at(text, pos)
):
reasons.append(f"사실과 반대 '{word}' — {present[0]} 값이 '{key_map[present[0]].value}' 다")
# 4. 홍보성 과장
for phrase in _PROMOTIONAL:
if phrase in text and phrase not in fact_values:
reasons.append(f"홍보성 표현 '{phrase}'")
return (not reasons), reasons