[STK8-05] ML sentiment.py Codex fallback 추가

작업 내용 (설계 의도)

변경 사항

sentiment.py_claude_runner는 현재 subprocess.run(["claude", "-p", ...]) 단일 호출만 한다. Claude 실패(subprocess 예외·빈 출력) 시 codex -q로 동일 프롬프트를 재시도하는 _fallback_runner를 추가한다.

analyze_headlines·analyze_headlines_v2의 기본 runner 파라미터를 _claude_runner_fallback_runner로 교체한다. runner 파라미터 인터페이스는 그대로 유지하므로 테스트 코드 변경 없음.

# 기존
def _claude_runner(prompt: str) -> str: ...
 
# 추가
def _codex_runner(prompt: str) -> str: ...
def _fallback_runner(prompt: str) -> str:
    # 1차: Claude
    # 실패 시 2차: Codex
    # 둘 다 실패 시: "" 반환 (기존 parse 실패 시 중립 처리 활용)

실패 감지 조건

  • subprocess.TimeoutExpired / subprocess.CalledProcessError
  • result.stdout이 빈 문자열
  • stdout에 context length / token limit / rate limit 포함

양쪽 다 실패 시

빈 문자열("") 반환 → 기존 _parse_v2_response / parse_sentiment의 중립 fallback이 작동. ML 레이어에서 예외를 던지지 않으므로 감성분석 실패가 전체 추천 흐름을 차단하지 않는다.

다이어그램

처리 흐름

sequenceDiagram
    participant AH as analyze_headlines_v2
    participant FR as _fallback_runner
    participant CL as _claude_runner
    participant CO as _codex_runner

    AH->>FR: prompt
    FR->>CL: prompt
    alt Claude 성공 (non-empty stdout)
        CL-->>FR: response
        FR-->>AH: response
    else Claude 실패
        FR->>CO: prompt
        alt Codex 성공
            CO-->>FR: response
            FR-->>AH: response
        else Codex 실패
            FR-->>AH: "" (빈 문자열)
        end
    end

테스트 케이스

  • Claude 성공 (non-empty) → Codex 미호출, Claude 응답 반환
  • Claude 빈 응답 → Codex fallback 호출됨
  • Claude TimeoutExpired → Codex fallback 호출됨
  • Codex 성공 → Codex 응답 반환
  • Claude·Codex 둘 다 실패 → 빈 문자열 반환, parse_sentiment 중립 처리
  • 기존 테스트에서 runner=mock_runner 주입 시 동작 변경 없음