from abc import ABC, abstractmethod from typing import Any from solar_eval.models.sample import EvalSample from solar_eval.providers.base import BaseProvider class BaseEvaluator(ABC): """Abstract base for all evaluators. `required_fields` 는 `evaluate()` 가 필요로 하는 `EvalSample` 필드 이름의 집합이다. 지표마다 요구 필드가 정말로 다르다 -- 교열의 `lcs_diff` 는 원문·정답· 출력 셋 다 필요하지만(3-way), RAG 의 `faithfulness` 는 원문이 필요 없는 식 (`.agents/01-plans/harness/2026-08-09-chosun-proofread-evalsample-migration.md` §3). 서브클래스는 이 값을 클래스 속성으로 선언한다. 구성에 따라 요구 필드가 달라지는 경우(예: 하위 평가기를 조합하는 `CompositeEvaluator`)는 `@property` 로 오버라이드 해도 된다 -- `validate_required_fields` 는 `self.required_fields` 로만 접근한다. """ required_fields: frozenset[str] = frozenset() @abstractmethod async def evaluate( self, sample: EvalSample, provider: BaseProvider | None = None, judge_model: str = "gpt-4o", ) -> dict[str, Any]: """`sample` 한 건을 채점한다. `{score, details}` 를 반환한다. 채점에 실패하면(judge 호출 예외, 잘못된 응답 등) 그 실패는 **예외로 전파한다** -- 점수(0.0 이든 1.0 이든)로 치환해 반환하지 않는다. fail-open(장애를 만점으로 둔갑시키는 것)을 만들지 않는 것이 이 계약의 핵심이다. 이 예외를 잡아 실패 샘플을 집계에서 분리하는 것은 호출자(runner.py/CLI)의 책임이다 -- 서브클래스는 그냥 던지기만 하면 된다. """ ... @abstractmethod def aggregate(self, results: list[dict[str, Any]]) -> dict[str, Any]: """`evaluate()` 가 성공한 결과들만 모아 전체 점수로 집계한다. `results` 에는 실패한 샘플이 섞여 들어오지 않는다(호출자가 이미 걸러낸다) -- 여기서 실패를 별도로 처리할 필요가 없다. """ ... def validate_required_fields(self, sample: EvalSample) -> None: """`required_fields` 가 `sample` 에 채워져 있는지 확인한다. 채점을 시작하기 전에 호출한다(러너/CLI 의 evaluate 호출부). 값이 `None` 이면 "채워지지 않음"으로 본다 -- 빈 문자열/빈 리스트는 유효한 값(예: 교정할 내용이 없는 원문)이라 통과시킨다. 조용히 넘기지 않고 즉시 실패시켜, 어느 필드가 왜 없는지가 로그에 바로 드러나게 한다. Raises: ValueError: `required_fields` 중 하나 이상이 `sample` 에서 `None` 인 경우. """ missing = sorted(f for f in self.required_fields if getattr(sample, f, None) is None) if missing: raise ValueError( f"{type(self).__name__} requires sample field(s) {missing} to be filled " f"(required_fields={sorted(self.required_fields)})" )