"""소개문·FAQ 의 근거 검증 — LLM 이 지어낸 문장을 걸러내는 규칙.""" import re from dataclasses import dataclass from typing import Optional # ── 시설 어휘 사전 ──────────────────────────────────────────────────────── 문장에 이 낱말이 나오면 대응 fact key 중 하나가 **반드시** 있어야 한다. _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 # 문장 분리용. _SENT_SPLIT = re.compile(r"(?<=[.!?。])\s*|\n+") # 홍보성·과장 표현. _PROMOTIONAL = ( "최고", "최상", "최적", "최대한", "완벽", "국내 최", "업계 1위", "1위", "명품", "럭셔리", "최고급", "독보적", "비교불가", "단연", "손꼽히는", "자랑하는", "환상적", "황홀", "잊지 못할", "특별한 추억", "아름다운", "쾌적한", "넓고", "저렴한", "합리적인 가격", ) # 숫자 뒤 한글 자릿수. _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]]]: """문자열의 숫자를 **등장 단위로** 뽑는다.""" 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: """그 위치가 의문문 안인지.""" 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 전용 — 질문이 '불가한 시설' 을 물었으면 **답변이 반드시 부정해야** 한다.""" 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 로 뒷받침되는지 검사한다.""" 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 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