"""추출 결과 검증 — "AI 가 원문에서 찾아온 것인가, 지어낸 것인가" 를 코드로 가른다. ★ 이 파일이 없으면 URL·붙여넣기 추출은 쓸 수 없다. 프롬프트로 "지어내지 마라" 를 아무리 적어도 그건 부탁이지 보장이 아니다. 보장은 하나뿐이다 — **모델이 적어 낸 근거 문장이 원문에 실제로 있는지 대조하는 것.** 없으면 그 항목을 버린다. 통과한 것만 fact 후보(UNVERIFIED)로 올라가고, 그 다음 관문은 여전히 사장님 확인이다. ★ 왜 '값' 이 아니라 '근거 문장' 을 대조하는가 값만 대조하면 "3" 같은 짧은 값이 원문 아무 데나 있다는 이유로 통과한다 ("최대 3인" 을 못 찾아도 "3층" 이 있으면 통과해 버린다). 근거 문장을 통째로 대조하면 그 값이 **그 맥락에서** 나왔다는 것까지 확인된다. """ import re from common.category_schema import CategorySchema # 원문 대조 전 정규화: 공백·따옴표·괄호 종류 차이로 어긋나는 것을 막는다. # ★ 글자 자체는 지우지 않는다 — 지우기 시작하면 "불가" 가 사라져 부정이 긍정으로 뒤집힌다. _WS = re.compile(r"\s+") _QUOTES = str.maketrans({ "“": '"', "”": '"', "‘": "'", "’": "'", """: '"', "-": "-", "–": "-", "—": "-", "~": "~", "~": "~", "(": "(", ")": ")", ":": ":", ",": ",", ".": ".", }) # evidence 가 이보다 짧으면 대조가 의미 없다("3" 은 원문 어디에나 있다). MIN_EVIDENCE = 6 # 값이 evidence 안에 있는지까지 볼 필요가 없는 타입. # bool 은 "true"/"false" 라 원문에 그 글자가 있을 리 없고, # 부호 판정은 아래 _bool_ok 가 따로 본다. _SKIP_VALUE_CHECK = {"bool"} _FALSE_WORDS = ("false", "불가", "없음", "없슴", "미제공", "제공하지", "안됨", "안 됨", "불가능", "금지", "않습니다", "제한") _TRUE_WORDS = ("true", "가능", "있음", "제공", "완비", "무료", "구비") _NUMBER_OK = re.compile(r"^\d+(\.\d+)?$") def normalize(text: str) -> str: """대조용 정규화. 공백을 하나로, 특수 문장부호를 표준형으로.""" return _WS.sub(" ", (text or "").translate(_QUOTES)).strip().lower() def loose(text: str) -> str: """값 대조 전용 — 공백과 자릿수 쉼표까지 지운다. ★ 왜 따로 두는가 (실측) number 필드는 규칙상 "19000" 으로 오는데 원문 표기는 "19,000원" 이다. normalize 만으로 대조하면 **모든 메뉴 가격이 '고쳐 쓴 값' 으로 반려된다** — 속초항아리물회 홈페이지에서 가격 5건이 통째로 날아갔다. 쉼표·공백은 같은 수를 다르게 적은 것일 뿐 값을 바꾸지 않으므로 여기서만 지운다. ★ 글자는 여전히 지우지 않는다 — "불가" 가 사라지면 부호가 뒤집힌다. """ return normalize(text).replace(",", "").replace(" ", "") def _bool_ok(value: str, evidence: str) -> tuple[bool, str]: """bool 값의 부호가 근거 문장과 맞는가. ★ 부호가 뒤집히면 "반려동물 동반 불가" 가 "동반 가능" 이 된다 — 숙박·음식점 모두 critical 항목이라 예약 클레임으로 직결된다. """ v = value.strip().lower() if v not in ("true", "false"): return False, f"bool 필드인데 값이 'true'/'false' 가 아니다: {value!r}" ev = evidence.lower() has_false = any(w in ev for w in _FALSE_WORDS) has_true = any(w in ev for w in _TRUE_WORDS) # 근거에 부정 표현이 있는데 true 로 올렸다면 뒤집힌 것이다. 그 반대도 같다. if v == "true" and has_false and not has_true: return False, "근거 문장은 부정인데 true 로 올렸다" if v == "false" and has_true and not has_false: return False, "근거 문장은 긍정인데 false 로 올렸다" return True, "" def verify( rows: list[dict], *, source_text: str, schema: CategorySchema, ) -> tuple[list[dict], list[tuple[str, str]]]: """추출 결과를 걸러 (통과, 반려) 로 나눈다. 반려는 조용히 버리지 않고 (항목, 사유) 로 남긴다 — 운영자가 "왜 이 값이 안 들어왔나" 를 이 목록으로 읽는다. 통과한 dict 는 {key, value, scope, unit_name, evidence} 형태다. """ haystack = normalize(source_text) passed: list[dict] = [] rejected: list[tuple[str, str]] = [] seen: set[tuple[str, str]] = set() for row in rows: key = str(row.get("key") or "").strip() value = str(row.get("value") or "").strip() unit_name = str(row.get("unit_name") or "").strip() evidence = str(row.get("evidence") or "").strip() label = f"{key}={value[:30]}" if key else "(key 없음)" # 1) 업종 스키마에 있는 key 인가. ★ 없는 key 는 fact 기록 단계에서도 거부되지만, # 여기서 먼저 걸러야 반려 사유가 남는다. spec = schema.get(key) if spec is None: rejected.append((label, f"업종 스키마에 없는 key: {key!r}")) continue if not value: rejected.append((label, "값이 비었다")) continue # 2) 스코프 계약. unit 스코프인데 이름이 없으면 어느 객실 것인지 알 수 없다. if spec.scope == "unit" and not unit_name: rejected.append((label, "unit 스코프인데 unit_name 이 없다")) continue if spec.scope == "place" and unit_name: unit_name = "" # place 스코프에 이름이 붙어 온 것은 무시하고 진행한다 # 3) 근거 문장이 원문에 실제로 있는가. ★ 이 검사가 이 파일의 존재 이유다. if len(evidence) < MIN_EVIDENCE: rejected.append((label, f"근거 문장이 너무 짧다({len(evidence)}자) — 대조할 수 없다")) continue if normalize(evidence) not in haystack: rejected.append((label, "근거 문장이 원문에 없다 — 지어낸 값으로 본다")) continue # 4) 타입 계약. if spec.type == "bool": ok, why = _bool_ok(value, evidence) if not ok: rejected.append((label, why)) continue elif spec.type == "number": if not _NUMBER_OK.match(value.replace(",", "")): rejected.append((label, f"number 필드인데 숫자가 아니다: {value!r}")) continue value = value.replace(",", "") # 5) 값이 근거 안에 실제로 적혀 있는가(bool 제외). # ★ 근거는 원문에서 가져왔는데 값이 그 안에 없다면, 값 쪽을 모델이 고쳐 쓴 것이다 # — "오후 3시" 를 "15:00" 으로 환산한 경우가 여기서 걸린다. if spec.type not in _SKIP_VALUE_CHECK and loose(value) not in loose(evidence): rejected.append((label, "값이 근거 문장 안에 없다 — 원문 표기를 고쳐 쓴 것으로 본다")) continue # 6) 같은 (key, unit) 중복은 첫 건만. dedupe = (key, unit_name) if dedupe in seen: rejected.append((label, "같은 key 가 이미 들어왔다")) continue seen.add(dedupe) passed.append({ "key": key, "value": value, "scope": spec.scope, "unit_name": unit_name or None, "evidence": evidence, }) return passed, rejected