Files
2026-07-13 13:39:38 +08:00

192 lines
5.7 KiB
Python

from __future__ import annotations
import asyncio
import os
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Protocol
from ..llm import LLM, ChatContext
from .judge import JudgmentResult
_evals_verbose = int(os.getenv("LIVEKIT_EVALS_VERBOSE", 0))
if TYPE_CHECKING:
from ..inference import LLMModels
class Evaluator(Protocol):
"""Protocol for any object that can evaluate a conversation."""
@property
def name(self) -> str:
"""Name identifying this evaluator."""
...
async def evaluate(
self,
*,
chat_ctx: ChatContext,
reference: ChatContext | None = None,
llm: LLM | None = None,
) -> JudgmentResult: ...
@dataclass
class EvaluationResult:
"""Result of evaluating a conversation with a group of judges."""
judgments: dict[str, JudgmentResult] = field(default_factory=dict)
"""Individual judgment results keyed by judge name."""
@property
def score(self) -> float:
"""Score from 0.0 to 1.0. Pass=1, maybe=0.5, fail=0."""
if not self.judgments:
return 0.0
total = 0.0
for j in self.judgments.values():
if j.passed:
total += 1.0
elif j.uncertain:
total += 0.5
return total / len(self.judgments)
@property
def all_passed(self) -> bool:
"""True if all judgments passed. Maybes count as not passed."""
return all(j.passed for j in self.judgments.values())
@property
def any_passed(self) -> bool:
"""True if at least one judgment passed."""
return any(j.passed for j in self.judgments.values())
@property
def majority_passed(self) -> bool:
"""True if more than half of the judgments passed."""
if not self.judgments:
return True
passed_count = sum(1 for j in self.judgments.values() if j.passed)
return passed_count > len(self.judgments) / 2
@property
def none_failed(self) -> bool:
"""True if no judgments explicitly failed. Maybes are allowed."""
return not any(j.failed for j in self.judgments.values())
class JudgeGroup:
"""A group of judges that evaluate conversations together.
Automatically tags the session with judgment results when called within a job context.
Example:
```python
async def on_session_end(ctx: JobContext) -> None:
judges = JudgeGroup(
llm="openai/gpt-4o-mini",
judges=[
task_completion_judge(),
accuracy_judge(),
],
)
report = ctx.make_session_report()
result = await judges.evaluate(report.chat_history)
# Results are automatically tagged to the session
```
"""
def __init__(
self,
*,
llm: LLM | LLMModels | str,
judges: list[Evaluator] | None = None,
) -> None:
"""Initialize a JudgeGroup.
Args:
llm: The LLM to use for evaluation. Can be an LLM instance or a model
string like "openai/gpt-4o-mini" (uses LiveKit inference gateway).
judges: The judges to run during evaluation.
"""
if isinstance(llm, str):
from ..inference import LLM as InferenceLLM
self._llm: LLM = InferenceLLM(llm)
else:
self._llm = llm
self._judges = judges or []
@property
def llm(self) -> LLM:
"""The LLM used for evaluation."""
return self._llm
@property
def judges(self) -> list[Evaluator]:
"""The judges to run during evaluation."""
return self._judges
async def evaluate(
self,
chat_ctx: ChatContext,
*,
reference: ChatContext | None = None,
) -> EvaluationResult:
"""Evaluate a conversation with all judges.
Automatically tags the session with results when called within a job context.
Args:
chat_ctx: The conversation to evaluate.
reference: Optional reference conversation for comparison.
Returns:
EvaluationResult containing all judgment results.
"""
from ..job import get_job_context
from ..log import logger
# Run all judges concurrently
async def run_judge(judge: Evaluator) -> tuple[str, JudgmentResult | BaseException]:
try:
result = await judge.evaluate(
chat_ctx=chat_ctx,
reference=reference,
llm=self._llm,
)
return judge.name, result
except Exception as e:
logger.warning(f"Judge '{judge.name}' failed: {e}")
return judge.name, e
results = await asyncio.gather(*[run_judge(j) for j in self._judges])
# Filter out failed judges
judgments: dict[str, JudgmentResult] = {}
for name, result in results:
if isinstance(result, JudgmentResult):
judgments[name] = result
evaluation_result = EvaluationResult(judgments=judgments)
if _evals_verbose:
print("\n+ JudgeGroup evaluation results:")
for name, result in results:
if isinstance(result, JudgmentResult):
print(f" [{name}] verdict={result.verdict}")
print(f" reasoning: {result.reasoning}\n")
else:
print(f" [{name}] ERROR: {result}\n")
# Auto-tag if running within a job context
try:
ctx = get_job_context()
ctx.tagger._evaluation(evaluation_result)
except RuntimeError:
pass # Not in a job context, skip tagging
return evaluation_result