"""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