2665d4eb60
Codex adversarial review (no-ship) 반영:
fix1: unit-aware numeric clearing
- _extract_numeric_corpus(): 단위별 bucket dict (exact_by_unit) +
ranges_by_unit (양방향 + 단방향 bound 통합)
- _within_unit_range / _close_to_unit_pool: 같은 unit 안에서만 매칭
bare answer 는 보수적으로 range/tolerance 패스 X
- 2-pass cleared_pairs (unit, digits): cross-unit cleared 절대 skip 안 함.
bare(None) 답변은 unit-anchored cleared 시 duplicate 로 skip
(콤마 normalize 부산물 보호 — Codex 케이스는 그대로 flag)
fix3: 최대/최소 bound semantics
- _APPROX_PREFIX_RE 에서 최대/최소 제거 (약/대략/거의/얼추 만 strip)
- _BOUND_PATTERN_RE: 최대 N → range (0, N-1), 최소 N → range (N+1, 1e18)
- 경계값 자체는 cleared 대상 아님 ("최대 100명" + answer "100명" → flag)
- bound span 내 숫자는 exact pool 에서 제외
기존 prefix strip / 콤마 / 부터 separator / 단위 동의어 / tolerance 4자리+ /
식별자성 단위 1자리 flag 동작 모두 유지.
tests/test_grounding_fabricated_number.py: 25 케이스 — 기존 17 + Codex
unit-mismatch 3 (won_vs_myeong_range/tol, pct_vs_myeong_range) + bound 5
(최대/최소 boundary/inner/outer).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
189 lines
6.1 KiB
Python
189 lines
6.1 KiB
Python
"""Phase 3.5 B1 (fix1+fix3): unit-aware fabricated_number + bound semantics.
|
|
|
|
기준:
|
|
- 단위 일치 시에만 exact/range/tolerance clear (fix1: Codex unit-mismatch regression 방지)
|
|
- 약/대략/거의/얼추 만 approx prefix strip; 최대/최소 는 bound operator 로 보존 (fix3)
|
|
- tolerance 는 양적 단위(_TOLERANCE_UNITS) + 4자리+ 만; 식별자성(_EXACT_ONLY_UNITS) 은 strict
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
|
|
# tests/ → 프로젝트 루트 → app/
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))
|
|
|
|
import pytest
|
|
|
|
from services.search.evidence_service import EvidenceItem
|
|
from services.search.grounding_check import check
|
|
|
|
|
|
def _ev(text: str, n: int = 1) -> EvidenceItem:
|
|
return EvidenceItem(
|
|
n=n,
|
|
chunk_id=None,
|
|
doc_id=100 + n,
|
|
title=f"doc{n}",
|
|
section_title=None,
|
|
span_text=text,
|
|
relevance=0.9,
|
|
rerank_score=0.85,
|
|
full_snippet=text,
|
|
source="llm",
|
|
)
|
|
|
|
|
|
def _has_fabricated(result, sub: str | None = None) -> bool:
|
|
for f in result.strong_flags:
|
|
if not f.startswith("fabricated_number:"):
|
|
continue
|
|
if sub is None or sub in f:
|
|
return True
|
|
return False
|
|
|
|
|
|
# ─── 콤마/prefix/range/단위 동의어/citation (기존 17 케이스) ──────
|
|
|
|
|
|
def test_comma_thousand_match():
|
|
r = check("질문", "총 1,000명 [1]", [_ev("총원은 1000명입니다.")])
|
|
assert not _has_fabricated(r, "1000")
|
|
|
|
|
|
def test_comma_thousand_reverse():
|
|
r = check("질문", "총 1000명 [1]", [_ev("총원은 1,000명입니다.")])
|
|
assert not _has_fabricated(r)
|
|
|
|
|
|
def test_approx_prefix_in_answer():
|
|
r = check("질문", "약 100명이 참여 [1]", [_ev("100명이 참여")])
|
|
assert not _has_fabricated(r)
|
|
|
|
|
|
def test_approx_prefix_in_evidence():
|
|
r = check("질문", "100명이 참여 [1]", [_ev("약 100명이 참여")])
|
|
assert not _has_fabricated(r)
|
|
|
|
|
|
def test_range_inner_value_passes():
|
|
r = check("질문", "약 150명 [1]", [_ev("100~200명 사이 추정")])
|
|
assert not _has_fabricated(r, "150")
|
|
|
|
|
|
def test_range_outer_value_flagged():
|
|
r = check("질문", "300명 [1]", [_ev("100~200명 사이 추정")])
|
|
assert _has_fabricated(r, "300")
|
|
|
|
|
|
def test_unit_synonym_in_to_myeong():
|
|
r = check("질문", "총 50인이 모임 [1]", [_ev("총 50명이 모임.")])
|
|
assert not _has_fabricated(r)
|
|
|
|
|
|
def test_unit_synonym_percent_to_pct():
|
|
r = check("질문", "비율 30퍼센트 [1]", [_ev("비율 30%이다.")])
|
|
assert not _has_fabricated(r)
|
|
|
|
|
|
def test_citation_marker_both_sides():
|
|
"""bug fix: evidence 측 [n] 미제거로 디지트 합쳐지던 케이스."""
|
|
r = check("질문", "가격 [1] 5,000원", [_ev("[2] 5,000원이 정확")])
|
|
assert not _has_fabricated(r)
|
|
|
|
|
|
def test_genuine_fabricated_number():
|
|
r = check("질문", "결과 777명 [1]", [_ev("500명, 300명을 받음.")])
|
|
assert _has_fabricated(r, "777")
|
|
|
|
|
|
def test_amount_4digit_tolerance_passes():
|
|
r = check("질문", "9,990원 [1]", [_ev("10,000원입니다.")])
|
|
assert not _has_fabricated(r)
|
|
|
|
|
|
def test_year_no_tolerance_flagged():
|
|
r = check("질문", "2024년 [1]", [_ev("2026년에 발효")])
|
|
assert _has_fabricated(r, "2024")
|
|
|
|
|
|
def test_article_no_tolerance_flagged():
|
|
r = check("질문", "제5조에 명시 [1]", [_ev("제6조에 따라")])
|
|
assert _has_fabricated(r)
|
|
|
|
|
|
def test_count_no_tolerance_flagged():
|
|
r = check("질문", "총 3회 위반 [1]", [_ev("총 4회 적발")])
|
|
assert _has_fabricated(r)
|
|
|
|
|
|
def test_three_digit_strict():
|
|
r = check("질문", "총 15개 [1]", [_ev("총 10개")])
|
|
assert _has_fabricated(r, "15")
|
|
|
|
|
|
def test_single_digit_ignored():
|
|
"""1자리 + 양적 단위 → 무시 (오탐 방지)."""
|
|
r = check("질문", "총 3개 발생 [1]", [_ev("관련 통계 별도")])
|
|
assert not _has_fabricated(r, "3개")
|
|
|
|
|
|
def test_range_korean_butter_separator():
|
|
r = check("질문", "약 150명 [1]", [_ev("100부터 200명까지 대상.")])
|
|
assert not _has_fabricated(r, "150")
|
|
|
|
|
|
# ─── fix1: unit-mismatch (Codex no-ship) ──────────────────
|
|
|
|
|
|
def test_won_vs_myeong_range_flagged():
|
|
"""answer '150원' vs evidence '100~200명' → 단위 불일치, flag 유지."""
|
|
r = check("질문", "약 150원이 든다 [1]", [_ev("대상은 100~200명")])
|
|
assert _has_fabricated(r, "150")
|
|
|
|
|
|
def test_won_vs_myeong_tolerance_flagged():
|
|
"""answer '9,990원' vs evidence '10,000명' → tolerance pool 단위 다름, flag 유지."""
|
|
r = check("질문", "9,990원 [1]", [_ev("10,000명입니다.")])
|
|
assert _has_fabricated(r, "9990")
|
|
|
|
|
|
def test_pct_vs_myeong_range_flagged():
|
|
"""answer '15%' vs evidence '10~20명' → 단위 불일치, flag 유지."""
|
|
r = check("질문", "약 15% [1]", [_ev("대상 10~20명")])
|
|
assert _has_fabricated(r, "15")
|
|
|
|
|
|
# ─── fix3: 최대/최소 bound semantics ───────────────────────
|
|
|
|
|
|
def test_choedae_exact_boundary_flagged():
|
|
"""evidence '최대 100명' + answer '100명' → 경계값 자체는 cleared 아님."""
|
|
r = check("질문", "100명이다 [1]", [_ev("최대 100명까지 가능")])
|
|
assert _has_fabricated(r, "100")
|
|
|
|
|
|
def test_choeso_exact_boundary_flagged():
|
|
"""evidence '최소 100명' + answer '100명' → 경계값 자체는 cleared 아님."""
|
|
r = check("질문", "100명이다 [1]", [_ev("최소 100명 이상 필요")])
|
|
assert _has_fabricated(r, "100")
|
|
|
|
|
|
def test_choedae_inner_value_passes():
|
|
"""evidence '최대 100명' + answer '50명' → bound 안, cleared."""
|
|
r = check("질문", "50명이다 [1]", [_ev("최대 100명까지 가능")])
|
|
assert not _has_fabricated(r, "50")
|
|
|
|
|
|
def test_choeso_above_value_passes():
|
|
"""evidence '최소 100명' + answer '150명' → bound 안, cleared."""
|
|
r = check("질문", "150명이다 [1]", [_ev("최소 100명 이상 필요")])
|
|
assert not _has_fabricated(r, "150")
|
|
|
|
|
|
def test_choedae_outer_value_flagged():
|
|
"""evidence '최대 100명' + answer '200명' → bound 밖, flag."""
|
|
r = check("질문", "200명이다 [1]", [_ev("최대 100명까지 가능")])
|
|
assert _has_fabricated(r, "200")
|