"""소개문·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