여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다. - ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제 - 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다 - eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다 파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이). site·frontend·admin tsc, site vitest 105 passed Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
211 lines
7.9 KiB
Python
211 lines
7.9 KiB
Python
"""소개문·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
|
|
|