fix(search): re-gate Tier 0 — synthesis self-refuse / timeout / empty answer 일관 처리
이전 버그: synthesis LLM self-refuse(status=completed + refused=True) 또는
timeout/parse_failed/llm_error/empty answer 시 grounding/verifier flag 가 0건이라
re-gate 체인이 `else clean` 분기로 빠지며 `completeness="full"` 초기값이 보존됨.
결과: `completeness=full + refused=True + re_gate=clean` 모순 row 생성.
실측: baseline v1-400char (2026-04-17) 223 row 중 24 (10.8%) 해당.
- LLM self-refuse: 20 (completed + refused=True)
- synthesis timeout: 4 (timeout + refused=False + empty answer)
수정: re-gate 최상위에 Tier 0 삽입 + 판정 로직을 `_detect_synthesis_failure()`
helper 로 분리. self-refuse 는 `synthesis_self_refuse`, 메커니즘 실패는
`synthesis_failed({status})` 라벨로 구분. no_reason fallback 도 refuse_reason 우선
활용하도록 보강.
테스트: tests/test_synthesis_failure_regate.py — self-refuse / timeout /
parse_failed / llm_error / empty answer / whitespace / valid answer 총 10 case.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
"""Phase 3.5 fix3: re-gate Tier 0 — synthesis 자체 실패 처리.
|
||||
|
||||
`_detect_synthesis_failure()` 단위 테스트.
|
||||
|
||||
기존 버그:
|
||||
synthesis LLM self-refuse (`sr.refused=True, status="completed"`) 또는
|
||||
timeout/parse_failed/llm_error 시 grounding/verifier flag 0건 → re-gate else clean
|
||||
분기로 빠져 `completeness="full"` 초기값이 남아 `full + refused=True` 모순.
|
||||
baseline v1-400char 에서 24/223 (10.8%) 해당.
|
||||
|
||||
Tier 0 판정:
|
||||
- LLM self-refuse (completed + refused) → "synthesis_self_refuse"
|
||||
- mechanical fail (timeout/parse_failed/llm_error) → "synthesis_failed({status})"
|
||||
- answer 공백 → "synthesis_failed({status})"
|
||||
- 유효 답변 → None (기존 tier 1~7 경로)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))
|
||||
|
||||
from api.search import _detect_synthesis_failure
|
||||
from services.search.synthesis_service import SynthesisResult
|
||||
|
||||
|
||||
def _sr(
|
||||
status: str = "completed",
|
||||
answer: str | None = "ok",
|
||||
refused: bool = False,
|
||||
refuse_reason: str | None = None,
|
||||
) -> SynthesisResult:
|
||||
return SynthesisResult(
|
||||
status=status, # type: ignore[arg-type]
|
||||
answer=answer,
|
||||
used_citations=[],
|
||||
confidence="low",
|
||||
refused=refused,
|
||||
refuse_reason=refuse_reason,
|
||||
elapsed_ms=100.0,
|
||||
cache_hit=False,
|
||||
)
|
||||
|
||||
|
||||
# ─── self-refuse 케이스 ──────────────────────────────────
|
||||
|
||||
|
||||
def test_llm_self_refuse_completed():
|
||||
"""LLM 이 JSON 에 refused=true 반환 → synthesis_self_refuse."""
|
||||
sr = _sr(status="completed", answer=None, refused=True, refuse_reason="범위 밖")
|
||||
assert _detect_synthesis_failure(sr) == "synthesis_self_refuse"
|
||||
|
||||
|
||||
def test_llm_self_refuse_with_answer_still_refused():
|
||||
"""refused=True 면 answer 있어도 Tier 0 처리 (일관성)."""
|
||||
sr = _sr(status="completed", answer="왜 답변함", refused=True)
|
||||
assert _detect_synthesis_failure(sr) == "synthesis_self_refuse"
|
||||
|
||||
|
||||
# ─── mechanical failure 케이스 ──────────────────────────
|
||||
|
||||
|
||||
def test_timeout():
|
||||
sr = _sr(status="timeout", answer=None, refused=False)
|
||||
assert _detect_synthesis_failure(sr) == "synthesis_failed(timeout)"
|
||||
|
||||
|
||||
def test_parse_failed():
|
||||
sr = _sr(status="parse_failed", answer=None, refused=False)
|
||||
assert _detect_synthesis_failure(sr) == "synthesis_failed(parse_failed)"
|
||||
|
||||
|
||||
def test_llm_error():
|
||||
sr = _sr(status="llm_error", answer=None, refused=False)
|
||||
assert _detect_synthesis_failure(sr) == "synthesis_failed(llm_error)"
|
||||
|
||||
|
||||
def test_refused_with_mechanical_fail_propagates_status():
|
||||
"""refused=True + status!=completed → synthesis_failed({status}) 형식."""
|
||||
sr = _sr(status="timeout", answer=None, refused=True)
|
||||
assert _detect_synthesis_failure(sr) == "synthesis_failed(timeout)"
|
||||
|
||||
|
||||
# ─── empty answer 케이스 ───────────────────────────────
|
||||
|
||||
|
||||
def test_empty_answer_completed():
|
||||
"""status=completed 인데 answer 공백 → synthesis_failed(completed)."""
|
||||
sr = _sr(status="completed", answer="", refused=False)
|
||||
assert _detect_synthesis_failure(sr) == "synthesis_failed(completed)"
|
||||
|
||||
|
||||
def test_whitespace_only_answer():
|
||||
"""공백/탭/개행만 있어도 empty 로 간주."""
|
||||
sr = _sr(status="completed", answer=" \n\t ", refused=False)
|
||||
assert _detect_synthesis_failure(sr) == "synthesis_failed(completed)"
|
||||
|
||||
|
||||
def test_none_answer_completed():
|
||||
"""answer=None + status=completed → failed."""
|
||||
sr = _sr(status="completed", answer=None, refused=False)
|
||||
assert _detect_synthesis_failure(sr) == "synthesis_failed(completed)"
|
||||
|
||||
|
||||
# ─── 유효 답변 케이스 (None 반환) ──────────────────────
|
||||
|
||||
|
||||
def test_valid_answer_returns_none():
|
||||
"""status=completed + answer 있고 refused=False → Tier 0 통과 (None)."""
|
||||
sr = _sr(status="completed", answer="교육 시간은 매년 6시간 이상이다 [1].", refused=False)
|
||||
assert _detect_synthesis_failure(sr) is None
|
||||
|
||||
|
||||
def test_skipped_status_with_answer_passes():
|
||||
"""status=skipped 는 Tier 0 대상 아님 — 초기 refusal gate 에서 이미 early-return 처리됨.
|
||||
|
||||
(skipped 는 여기까지 도달하지 않는다는 전제. 만약 도달하더라도 refused 가 True 일 것.)
|
||||
"""
|
||||
sr = _sr(status="skipped", answer="abc", refused=False)
|
||||
# 이 경우 Tier 0 미발동 (answer 있고 refused 아님) — 정상 경로로 나감.
|
||||
assert _detect_synthesis_failure(sr) is None
|
||||
Reference in New Issue
Block a user