"""추출 결과 검증 — "AI 가 원문에서 찾아온 것인가, 지어낸 것인가" 를 코드로 가른다.""" import re from common.category_schema import CategorySchema # 원문 대조 전 정규화: 공백·따옴표·괄호 종류 차이로 어긋나는 것을 막는다. _WS = re.compile(r"\s+") _QUOTES = str.maketrans({ "“": '"', "”": '"', "‘": "'", "’": "'", """: '"', "-": "-", "–": "-", "—": "-", "~": "~", "~": "~", "(": "(", ")": ")", ":": ":", ",": ",", ".": ".", }) # evidence 가 이보다 짧으면 대조가 의미 없다("3" 은 원문 어디에나 있다). MIN_EVIDENCE = 6 # 값이 evidence 안에 있는지까지 볼 필요가 없는 타입. _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: """값 대조 전용 — 공백과 자릿수 쉼표까지 지운다.""" return normalize(text).replace(",", "").replace(" ", "") def _bool_ok(value: str, evidence: str) -> tuple[bool, str]: """bool 값의 부호가 근거 문장과 맞는가.""" 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) 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]]]: """추출 결과를 걸러 (통과, 반려) 로 나눈다.""" 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 인가. spec = schema.get(key) if spec is None: rejected.append((label, f"업종 스키마에 없는 key: {key!r}")) continue if not value: rejected.append((label, "값이 비었다")) continue # 2) 스코프 계약. 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 제외). 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