feat(ask): Phase 3.5b guardrails — verifier + telemetry + grounding 강화
Phase 3.5a(classifier+refusal gate+grounding) 위에 4개 Item 추가: Item 0: ask_events telemetry 배선 - AskEvent ORM 모델 + record_ask_event() — ask_events INSERT 완성 - defense_layers에 input_snapshot(query, chunks, answer) 저장 - refused/normal 두 경로 모두 telemetry 호출 Item 3: evidence 간 numeric conflict detection - 동일 단위 다른 숫자 → weak flag - "이상/이하/초과/미만" threshold 표현 → skip (FP 방지) Item 4: fabricated_number normalization 개선 - 단위 접미사 건/원 추가, 범위 표현(10~20%) 양쪽 추출 - bare number 2자리 이상만 (1자리 FP 제거) Item 1: exaone semantic verifier (판단권 잠금 배선) - verifier_service.py — 3s timeout, circuit breaker, severity 3단계 - direct_negation만 strong, numeric/intent→medium, 나머지→weak - verifier strong 단독 refuse 금지 — grounding과 교차 필수 - 6-tier re-gate (4라운드 리뷰 확정) - grounding strong 2+ OR max_score<0.2 → verifier skip Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -42,17 +42,32 @@ class GroundingResult:
|
||||
weak_flags: list[str]
|
||||
|
||||
|
||||
_UNIT_CHARS = r'명인개%년월일조항호세건원'
|
||||
|
||||
# "이상/이하/초과/미만" — threshold 표현 (numeric conflict 에서 skip 대상)
|
||||
_THRESHOLD_SUFFIXES = re.compile(r'이상|이하|초과|미만')
|
||||
|
||||
|
||||
def _extract_number_literals(text: str) -> set[str]:
|
||||
"""숫자 + 단위 추출 + normalize."""
|
||||
raw = set(re.findall(r'\d[\d,.]*\s*[명인개%년월일조항호세]\w{0,2}', text))
|
||||
"""숫자 + 단위 추출 + normalize (Phase 3.5b 개선)."""
|
||||
# 1. 숫자 + 한국어 단위 접미사
|
||||
raw = set(re.findall(rf'\d[\d,.]*\s*[{_UNIT_CHARS}]\w{{0,2}}', text))
|
||||
# 2. 범위 표현 (10~20%, 100-200명 등) — 양쪽 숫자 각각 추출
|
||||
for m in re.finditer(
|
||||
rf'(\d[\d,.]*)\s*[~\-–]\s*(\d[\d,.]*)\s*([{_UNIT_CHARS}])',
|
||||
text,
|
||||
):
|
||||
raw.add(m.group(1) + m.group(3))
|
||||
raw.add(m.group(2) + m.group(3))
|
||||
# 3. normalize
|
||||
normalized = set()
|
||||
for r in raw:
|
||||
normalized.add(r.strip())
|
||||
num_only = re.match(r'[\d,.]+', r)
|
||||
if num_only:
|
||||
normalized.add(num_only.group().replace(',', ''))
|
||||
# 단독 숫자도 추출
|
||||
for d in re.findall(r'\b\d+\b', text):
|
||||
# 4. 단독 숫자 (2자리 이상만 — 1자리는 오탐 과다)
|
||||
for d in re.findall(r'\b(\d{2,})\b', text):
|
||||
normalized.add(d)
|
||||
return normalized
|
||||
|
||||
@@ -62,6 +77,73 @@ def _extract_content_tokens(text: str) -> set[str]:
|
||||
return set(re.findall(r'[가-힣]{2,}|[a-zA-Z]{3,}', text))
|
||||
|
||||
|
||||
def _parse_number_with_unit(literal: str) -> tuple[str, str] | None:
|
||||
"""숫자 리터럴에서 (digits_only, unit) 분리. 단위 없으면 None."""
|
||||
m = re.match(rf'([\d,.]+)\s*([{_UNIT_CHARS}])', literal)
|
||||
if not m:
|
||||
return None
|
||||
digits = m.group(1).replace(',', '')
|
||||
unit = m.group(2)
|
||||
return (digits, unit)
|
||||
|
||||
|
||||
def _check_evidence_numeric_conflicts(evidence: list["EvidenceItem"]) -> list[str]:
|
||||
"""evidence 간 숫자 충돌 감지 (Phase 3.5b). evidence >= 2 일 때만 활성.
|
||||
|
||||
동일 단위, 다른 숫자 → weak flag. "이상/이하/초과/미만" 포함 시 skip.
|
||||
bare number 는 비교 안 함 (조항 번호 등 false positive 방지).
|
||||
"""
|
||||
if len(evidence) < 2:
|
||||
return []
|
||||
|
||||
# 각 evidence 에서 단위 있는 숫자 + threshold 여부 추출
|
||||
# {evidence_idx: [(digits, unit, has_threshold), ...]}
|
||||
per_evidence: dict[int, list[tuple[str, str, bool]]] = {}
|
||||
for idx, ev in enumerate(evidence):
|
||||
nums = re.findall(
|
||||
rf'\d[\d,.]*\s*[{_UNIT_CHARS}]\w{{0,4}}',
|
||||
ev.span_text,
|
||||
)
|
||||
entries = []
|
||||
for raw in nums:
|
||||
parsed = _parse_number_with_unit(raw)
|
||||
if not parsed:
|
||||
continue
|
||||
has_thr = bool(_THRESHOLD_SUFFIXES.search(raw))
|
||||
entries.append((parsed[0], parsed[1], has_thr))
|
||||
if entries:
|
||||
per_evidence[idx] = entries
|
||||
|
||||
if len(per_evidence) < 2:
|
||||
return []
|
||||
|
||||
# 단위별로 evidence 간 숫자 비교
|
||||
# {unit: {digits: [evidence_idx, ...]}}
|
||||
unit_map: dict[str, dict[str, list[int]]] = {}
|
||||
for idx, entries in per_evidence.items():
|
||||
for digits, unit, has_thr in entries:
|
||||
if has_thr:
|
||||
continue # threshold 표현은 skip
|
||||
if unit not in unit_map:
|
||||
unit_map[unit] = {}
|
||||
if digits not in unit_map[unit]:
|
||||
unit_map[unit][digits] = []
|
||||
if idx not in unit_map[unit][digits]:
|
||||
unit_map[unit][digits].append(idx)
|
||||
|
||||
flags: list[str] = []
|
||||
for unit, digits_map in unit_map.items():
|
||||
distinct_values = list(digits_map.keys())
|
||||
if len(distinct_values) >= 2:
|
||||
# 가장 많이 등장하는 2개 비교
|
||||
top2 = sorted(distinct_values, key=lambda d: len(digits_map[d]), reverse=True)[:2]
|
||||
flags.append(
|
||||
f"evidence_numeric_conflict:{top2[0]}{unit}_vs_{top2[1]}{unit}"
|
||||
)
|
||||
|
||||
return flags
|
||||
|
||||
|
||||
def check(
|
||||
query: str,
|
||||
answer: str,
|
||||
@@ -113,6 +195,10 @@ def check(
|
||||
if len(s.strip()) > 20 and not re.search(r'\[\d+\]', s):
|
||||
weak.append(f"uncited_claim:{s[:40]}")
|
||||
|
||||
# ── Weak: evidence 간 숫자 충돌 (Phase 3.5b) ──
|
||||
conflicts = _check_evidence_numeric_conflicts(evidence)
|
||||
weak.extend(conflicts)
|
||||
|
||||
# ── Weak 2: token overlap ──
|
||||
answer_tokens = _extract_content_tokens(answer)
|
||||
evidence_tokens = _extract_content_tokens(evidence_text)
|
||||
|
||||
Reference in New Issue
Block a user