여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다. - 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>
128 lines
4.9 KiB
Python
128 lines
4.9 KiB
Python
"""추출 결과 검증 — "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
|