From c89b951e7165ef8699384f9c563facb1121203f1 Mon Sep 17 00:00:00 2001 From: Kevin Messiaen Date: Tue, 31 Mar 2026 09:05:51 +0700 Subject: [PATCH 1/2] feat(giskard-checks): add Correctness LLM judge check Register correctness judge with reference answer, Jinja prompt, public exports, and unit tests. Sync uv.lock for editable package versions. Made-with: Cursor --- .../src/giskard/checks/__init__.py | 2 + .../src/giskard/checks/builtin/__init__.py | 10 +- .../src/giskard/checks/judges/__init__.py | 2 + .../src/giskard/checks/judges/correctness.py | 139 ++++++++++++++++++ .../checks/prompts/judges/correctness.j2 | 58 ++++++++ .../tests/builtin/test_correctness.py | 107 ++++++++++++++ uv.lock | 6 +- 7 files changed, 320 insertions(+), 4 deletions(-) create mode 100644 libs/giskard-checks/src/giskard/checks/judges/correctness.py create mode 100644 libs/giskard-checks/src/giskard/checks/prompts/judges/correctness.j2 create mode 100644 libs/giskard-checks/tests/builtin/test_correctness.py diff --git a/libs/giskard-checks/src/giskard/checks/__init__.py b/libs/giskard-checks/src/giskard/checks/__init__.py index 34dab576a8..0ce9eabe45 100644 --- a/libs/giskard-checks/src/giskard/checks/__init__.py +++ b/libs/giskard-checks/src/giskard/checks/__init__.py @@ -40,6 +40,7 @@ from .judges import ( BaseLLMCheck, Conformity, + Correctness, Groundedness, LLMCheckResult, LLMJudge, @@ -88,6 +89,7 @@ "BaseLLMCheck", "LLMCheckResult", "Conformity", + "Correctness", "Equals", "NotEquals", "LesserThan", diff --git a/libs/giskard-checks/src/giskard/checks/builtin/__init__.py b/libs/giskard-checks/src/giskard/checks/builtin/__init__.py index 14c14b75c4..5a2786b709 100644 --- a/libs/giskard-checks/src/giskard/checks/builtin/__init__.py +++ b/libs/giskard-checks/src/giskard/checks/builtin/__init__.py @@ -1,7 +1,14 @@ """Built-in check implementations and helpers.""" # Import judge checks from new location and re-export for backward compatibility -from ..judges import BaseLLMCheck, Conformity, Groundedness, LLMCheckResult, LLMJudge +from ..judges import ( + BaseLLMCheck, + Conformity, + Correctness, + Groundedness, + LLMCheckResult, + LLMJudge, +) # Import comparison checks (staying in builtin) from .comparison import ( @@ -31,6 +38,7 @@ "GreaterEquals", "Groundedness", "Conformity", + "Correctness", "LLMJudge", "SemanticSimilarity", "BaseLLMCheck", diff --git a/libs/giskard-checks/src/giskard/checks/judges/__init__.py b/libs/giskard-checks/src/giskard/checks/judges/__init__.py index 8436175838..22e7dd8759 100644 --- a/libs/giskard-checks/src/giskard/checks/judges/__init__.py +++ b/libs/giskard-checks/src/giskard/checks/judges/__init__.py @@ -2,6 +2,7 @@ from .base import BaseLLMCheck, LLMCheckResult from .conformity import Conformity +from .correctness import Correctness from .groundedness import Groundedness from .judge import LLMJudge @@ -9,6 +10,7 @@ "BaseLLMCheck", "LLMCheckResult", "Conformity", + "Correctness", "Groundedness", "LLMJudge", ] diff --git a/libs/giskard-checks/src/giskard/checks/judges/correctness.py b/libs/giskard-checks/src/giskard/checks/judges/correctness.py new file mode 100644 index 0000000000..e434e52de7 --- /dev/null +++ b/libs/giskard-checks/src/giskard/checks/judges/correctness.py @@ -0,0 +1,139 @@ +from typing import Any, override + +from giskard.agents.workflow import TemplateReference +from giskard.core import provide_not_none +from pydantic import Field + +from ..core import Trace +from ..core.check import Check +from ..core.extraction import JSONPathStr, provided_or_resolve +from .base import BaseLLMCheck + + +@Check.register("correctness") +class Correctness[InputType, OutputType, TraceType: Trace]( # pyright: ignore[reportMissingTypeArgument] + BaseLLMCheck[InputType, OutputType, TraceType] +): + """LLM-based check that validates an answer against a reference (ground-truth). + + Uses an LLM to decide whether the agent's answer is correct relative to a + reference answer. The judge prompt receives the same ``Trace`` instance that is + passed to ``run`` as the "conversation": the full interaction + history, not a separate string field. + + **How the trace appears in the prompt** + + Template rendering (via the agents Jinja environment) formats values for the LLM. + If the trace type implements ``_repr_prompt_()`` (see + ``giskard.agents.templates.LLMFormattable``), that method supplies the + conversation text. Otherwise the trace + is serialized as indented JSON from ``model_dump()`` (Pydantic). + + Attributes + ---------- + description : str | None + Description of the agent under test. + description_key : str + JSONPath expression to extract the description from the trace + (default: ``trace.annotations.description``). + + Can use ``trace.last`` (preferred) or ``trace.interactions[-1]`` for JSONPath expressions. + answer : str | None + The agent's answer to evaluate (typically the response to the final user message). + answer_key : str + JSONPath expression to extract the answer from the trace + (default: ``trace.last.outputs``). + reference_answer : str | None + Reference answer representing the correct response. + reference_answer_key : str + JSONPath expression to extract the reference answer from the trace + (default: ``trace.last.metadata.reference_answer``). + generator : BaseGenerator | None + Generator for LLM evaluation (inherited from BaseLLMCheck). + + Examples + -------- + >>> from giskard.agents.generators import Generator + >>> from giskard.checks import Correctness, Interaction, Trace + >>> trace = Trace( + ... annotations={"description": "A factual Q&A assistant"}, + ... interactions=[ + ... Interaction( + ... inputs="Capital of France?", + ... outputs="Paris.", + ... metadata={"reference_answer": "The capital of France is Paris."}, + ... ) + ... ], + ... ) + >>> check = Correctness(generator=Generator(model="openai/gpt-4o")) + >>> # await check.run(trace) + """ + + description: str | None = Field( + default=None, description="Description of the agent under test" + ) + description_key: JSONPathStr = Field( + default="trace.annotations.description", + description="Key to extract the agent description from the trace", + ) + answer: str | None = Field( + default=None, description="Agent answer to evaluate for correctness" + ) + answer_key: JSONPathStr = Field( + default="trace.last.outputs", + description="Key to extract the answer from the trace", + ) + reference_answer: str | None = Field( + default=None, description="Reference (ground-truth) answer" + ) + reference_answer_key: JSONPathStr = Field( + default="trace.last.metadata.reference_answer", + description="Key to extract the reference answer from the trace", + ) + + @override + def get_prompt(self) -> TemplateReference: + return TemplateReference(template_name="giskard.checks::judges/correctness.j2") + + @override + async def get_inputs(self, trace: Trace[InputType, OutputType]) -> dict[str, Any]: + """Build template variables from the trace and explicit field overrides. + + The ``conversation`` variable in the judge template is the ``trace`` object + itself (formatted for the LLM as described in the class docstring). + + Parameters + ---------- + trace : Trace + Trace passed to ``run``; also supplied as template input ``conversation``. + + Returns + ------- + dict[str, Any] + Template variables: ``description`` and ``answer`` and ``reference_answer`` + as strings; ``conversation`` as the trace instance. + """ + return { + "description": str( + provided_or_resolve( + trace, + key=self.description_key, + value=provide_not_none(self.description), + ) + ), + "conversation": trace, + "answer": str( + provided_or_resolve( + trace, + key=self.answer_key, + value=provide_not_none(self.answer), + ) + ), + "reference_answer": str( + provided_or_resolve( + trace, + key=self.reference_answer_key, + value=provide_not_none(self.reference_answer), + ) + ), + } diff --git a/libs/giskard-checks/src/giskard/checks/prompts/judges/correctness.j2 b/libs/giskard-checks/src/giskard/checks/prompts/judges/correctness.j2 new file mode 100644 index 0000000000..9c7a9120c8 --- /dev/null +++ b/libs/giskard-checks/src/giskard/checks/prompts/judges/correctness.j2 @@ -0,0 +1,58 @@ +Your role is to evaluate whether an AI agent's answer is correct, using a provided reference (ground-truth) answer. + +You will receive: +- A description of the agent under test +- The full conversation between the user and the agent +- The agent's answer to evaluate (typically the response to the final user message) +- A reference answer that represents the correct response + +Your task is to decide whether the agent's answer is correct **relative to the reference answer**, taking the **full conversation history** into account. The agent's wording may differ from the reference if the meaning and factual content are equivalent. + +## Evaluation Criteria + +1. **Reference alignment:** The agent's answer should match the substance of the reference answer: same conclusions, facts, and recommendations unless the conversation clearly narrows what was asked. +2. **Conversation context:** Use prior turns to interpret the last user request and whether the agent's answer appropriately addresses it. Do not ignore relevant context from earlier messages. +3. **Paraphrasing:** Synonyms, rephrasing, and different structure are acceptable when they preserve the same meaning as the reference answer. +4. **Precise data:** For numbers, dates, names, links, codes, or other exact values, the agent's answer should agree with the reference answer unless the conversation explicitly allows approximation or a range. +5. **Errors and omissions:** If the agent's answer is factually wrong, contradicts the reference, or fails to address what the reference answer covers for the same question, the evaluation should be false. Minor stylistic differences alone are not a failure. + +## Evaluation Strategy + +1. Read the agent description to understand role and constraints. +2. Read the conversation and identify the last user question and what a correct answer should resolve. +3. Compare the agent's answer to the reference answer in light of that question and the conversation history. +4. If the agent's answer is materially equivalent to the reference (allowing paraphrase), the evaluation should be true. +5. If the agent's answer is wrong, incomplete in a way that matters relative to the reference, or contradicted by the reference, the evaluation should be false and you should give a clear reason focused on the mismatch. + +## Markers + +Markers ..., ..., ..., and ... indicate where each input is. Everything inside a marker belongs to that category. + +------------------- + + +{{ description }} + + +------------------- + + +{{ conversation }} + + +------------------- + + +{{ answer }} + + +------------------- + + +{{ reference_answer }} + + +------------------- + +**Output Format:** +{{ _instr_output }} diff --git a/libs/giskard-checks/tests/builtin/test_correctness.py b/libs/giskard-checks/tests/builtin/test_correctness.py new file mode 100644 index 0000000000..a86ab54f8b --- /dev/null +++ b/libs/giskard-checks/tests/builtin/test_correctness.py @@ -0,0 +1,107 @@ +import json +from typing import Any, override + +from giskard.agents.chat import Message +from giskard.agents.generators._types import Response +from giskard.agents.generators.base import BaseGenerator, GenerationParams +from giskard.checks import CheckStatus, Correctness, Interaction, Trace +from pydantic import Field + + +class LLMTrace(Trace[str, str], frozen=True): + def _repr_prompt_(self) -> str: + return "\n".join( + [ + f"[user]: {interaction.inputs}\n[assistant]: {interaction.outputs}" + for interaction in self.interactions + ] + ) + + +class MockGenerator(BaseGenerator): + passed: bool + reason: str | None + calls: list[list[Message]] = Field(default_factory=list) + + @override + async def _call_model( + self, + messages: list[Message], + params: GenerationParams, + metadata: dict[str, Any] | None = None, + ) -> Response: + self.calls.append(messages) + return Response( + message=Message( + role="assistant", + content=json.dumps({"passed": self.passed, "reason": self.reason}), + ), + finish_reason="stop", + ) + + +async def test_run_returns_success() -> None: + generator = MockGenerator(passed=True, reason="Matches reference") + correctness = Correctness( + generator=generator, + description="Q&A bot", + answer="Paris.", + reference_answer="The capital of France is Paris.", + ) + result = await correctness.run(Trace()) + assert result.status == CheckStatus.PASS + assert result.details["reason"] == "Matches reference" + + assert len(generator.calls) == 1 + assert len(generator.calls[0]) > 0 + + +async def test_run_returns_failure() -> None: + generator = MockGenerator(passed=False, reason="Contradicts reference") + correctness = Correctness( + generator=generator, + description="Q&A bot", + answer="Lyon.", + reference_answer="The capital of France is Paris.", + ) + result = await correctness.run(Trace()) + assert result.status == CheckStatus.FAIL + assert result.details["reason"] == "Contradicts reference" + + assert len(generator.calls) == 1 + + +async def test_inputs_from_trace() -> None: + generator = MockGenerator(passed=True, reason=None) + correctness = Correctness(generator=generator) + interaction = Interaction( + inputs="Capital of France?", + outputs="Paris.", + metadata={ + "reference_answer": "Paris is the capital of France.", + }, + ) + trace = LLMTrace( + annotations={"description": "Factual assistant"}, + interactions=[interaction], + ) + result = await correctness.run(trace) + + assert result.status == CheckStatus.PASS + assert result.details["reason"] is None + + assert len(generator.calls) == 1 + message = generator.calls[0][0] + assert isinstance(message.content, str) + assert ( + "\nFactual assistant\n" + in message.content + ) + assert ( + f"\n{trace._repr_prompt_()}\n" in message.content + ) + assert "\nParis.\n" in message.content + assert ( + "\nParis is the capital of France.\n" + in message.content + ) diff --git a/uv.lock b/uv.lock index 9523bcd8d1..7ab81d6609 100644 --- a/uv.lock +++ b/uv.lock @@ -719,7 +719,7 @@ dev = [ [[package]] name = "giskard-agents" -version = "1.0.2a1" +version = "1.0.2b1" source = { editable = "libs/giskard-agents" } dependencies = [ { name = "giskard-core" }, @@ -748,7 +748,7 @@ requires-dist = [ [[package]] name = "giskard-checks" -version = "1.0.1a2" +version = "1.0.1b1" source = { editable = "libs/giskard-checks" } dependencies = [ { name = "giskard-agents" }, @@ -773,7 +773,7 @@ requires-dist = [ [[package]] name = "giskard-core" -version = "1.0.1a1" +version = "1.0.1b2" source = { editable = "libs/giskard-core" } dependencies = [ { name = "pydantic" }, From cebbaa92c3279559d522983a5f289ecb8c07458c Mon Sep 17 00:00:00 2001 From: Kevin Messiaen <114553769+kevinmessiaen@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:26:21 +0700 Subject: [PATCH 2/2] refactor(checks): address review on Correctness check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migrate to pydantic MISSING sentinel (drops removed provide_not_none), matching sibling judges - Rename `description`→`agent_description` to stop overriding base Check.description field - Use shared testing_utils MockJudgeGenerator/LLMTrace in tests - Remove stray docstring note; align TemplateReference import Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BzUi8GmbQZToZ4BgCdasru --- .../src/giskard/checks/judges/correctness.py | 44 +++++++------- .../checks/prompts/judges/correctness.j2 | 2 +- .../tests/builtin/test_correctness.py | 60 +++---------------- 3 files changed, 31 insertions(+), 75 deletions(-) diff --git a/libs/giskard-checks/src/giskard/checks/judges/correctness.py b/libs/giskard-checks/src/giskard/checks/judges/correctness.py index e434e52de7..92bc2fe19b 100644 --- a/libs/giskard-checks/src/giskard/checks/judges/correctness.py +++ b/libs/giskard-checks/src/giskard/checks/judges/correctness.py @@ -1,8 +1,8 @@ from typing import Any, override -from giskard.agents.workflow import TemplateReference -from giskard.core import provide_not_none +from giskard.agents import TemplateReference from pydantic import Field +from pydantic.experimental.missing_sentinel import MISSING from ..core import Trace from ..core.check import Check @@ -31,19 +31,17 @@ class Correctness[InputType, OutputType, TraceType: Trace]( # pyright: ignore[r Attributes ---------- - description : str | None + agent_description : str Description of the agent under test. - description_key : str - JSONPath expression to extract the description from the trace + agent_description_key : str + JSONPath expression to extract the agent description from the trace (default: ``trace.annotations.description``). - - Can use ``trace.last`` (preferred) or ``trace.interactions[-1]`` for JSONPath expressions. - answer : str | None + answer : str The agent's answer to evaluate (typically the response to the final user message). answer_key : str JSONPath expression to extract the answer from the trace (default: ``trace.last.outputs``). - reference_answer : str | None + reference_answer : str Reference answer representing the correct response. reference_answer_key : str JSONPath expression to extract the reference answer from the trace @@ -69,22 +67,22 @@ class Correctness[InputType, OutputType, TraceType: Trace]( # pyright: ignore[r >>> # await check.run(trace) """ - description: str | None = Field( - default=None, description="Description of the agent under test" + agent_description: str | MISSING = Field( + default=MISSING, description="Description of the agent under test" ) - description_key: JSONPathStr = Field( + agent_description_key: JSONPathStr = Field( default="trace.annotations.description", description="Key to extract the agent description from the trace", ) - answer: str | None = Field( - default=None, description="Agent answer to evaluate for correctness" + answer: str | MISSING = Field( + default=MISSING, description="Agent answer to evaluate for correctness" ) answer_key: JSONPathStr = Field( default="trace.last.outputs", description="Key to extract the answer from the trace", ) - reference_answer: str | None = Field( - default=None, description="Reference (ground-truth) answer" + reference_answer: str | MISSING = Field( + default=MISSING, description="Reference (ground-truth) answer" ) reference_answer_key: JSONPathStr = Field( default="trace.last.metadata.reference_answer", @@ -110,15 +108,15 @@ async def get_inputs(self, trace: Trace[InputType, OutputType]) -> dict[str, Any Returns ------- dict[str, Any] - Template variables: ``description`` and ``answer`` and ``reference_answer`` - as strings; ``conversation`` as the trace instance. + Template variables: ``agent_description`` and ``answer`` and + ``reference_answer`` as strings; ``conversation`` as the trace instance. """ return { - "description": str( + "agent_description": str( provided_or_resolve( trace, - key=self.description_key, - value=provide_not_none(self.description), + key=self.agent_description_key, + value=self.agent_description, ) ), "conversation": trace, @@ -126,14 +124,14 @@ async def get_inputs(self, trace: Trace[InputType, OutputType]) -> dict[str, Any provided_or_resolve( trace, key=self.answer_key, - value=provide_not_none(self.answer), + value=self.answer, ) ), "reference_answer": str( provided_or_resolve( trace, key=self.reference_answer_key, - value=provide_not_none(self.reference_answer), + value=self.reference_answer, ) ), } diff --git a/libs/giskard-checks/src/giskard/checks/prompts/judges/correctness.j2 b/libs/giskard-checks/src/giskard/checks/prompts/judges/correctness.j2 index 9c7a9120c8..263eaf9d5c 100644 --- a/libs/giskard-checks/src/giskard/checks/prompts/judges/correctness.j2 +++ b/libs/giskard-checks/src/giskard/checks/prompts/judges/correctness.j2 @@ -31,7 +31,7 @@ Markers ..., ... -{{ description }} +{{ agent_description }} ------------------- diff --git a/libs/giskard-checks/tests/builtin/test_correctness.py b/libs/giskard-checks/tests/builtin/test_correctness.py index a86ab54f8b..9ecfa7b09b 100644 --- a/libs/giskard-checks/tests/builtin/test_correctness.py +++ b/libs/giskard-checks/tests/builtin/test_correctness.py @@ -1,50 +1,14 @@ -import json -from typing import Any, override - -from giskard.agents.chat import Message -from giskard.agents.generators._types import Response -from giskard.agents.generators.base import BaseGenerator, GenerationParams from giskard.checks import CheckStatus, Correctness, Interaction, Trace -from pydantic import Field - - -class LLMTrace(Trace[str, str], frozen=True): - def _repr_prompt_(self) -> str: - return "\n".join( - [ - f"[user]: {interaction.inputs}\n[assistant]: {interaction.outputs}" - for interaction in self.interactions - ] - ) - - -class MockGenerator(BaseGenerator): - passed: bool - reason: str | None - calls: list[list[Message]] = Field(default_factory=list) - @override - async def _call_model( - self, - messages: list[Message], - params: GenerationParams, - metadata: dict[str, Any] | None = None, - ) -> Response: - self.calls.append(messages) - return Response( - message=Message( - role="assistant", - content=json.dumps({"passed": self.passed, "reason": self.reason}), - ), - finish_reason="stop", - ) +from ..testing_utils import LLMTrace +from ..testing_utils import MockJudgeGenerator as MockGenerator async def test_run_returns_success() -> None: generator = MockGenerator(passed=True, reason="Matches reference") correctness = Correctness( generator=generator, - description="Q&A bot", + agent_description="Q&A bot", answer="Paris.", reference_answer="The capital of France is Paris.", ) @@ -60,7 +24,7 @@ async def test_run_returns_failure() -> None: generator = MockGenerator(passed=False, reason="Contradicts reference") correctness = Correctness( generator=generator, - description="Q&A bot", + agent_description="Q&A bot", answer="Lyon.", reference_answer="The capital of France is Paris.", ) @@ -91,17 +55,11 @@ async def test_inputs_from_trace() -> None: assert result.details["reason"] is None assert len(generator.calls) == 1 - message = generator.calls[0][0] - assert isinstance(message.content, str) - assert ( - "\nFactual assistant\n" - in message.content - ) - assert ( - f"\n{trace._repr_prompt_()}\n" in message.content - ) - assert "\nParis.\n" in message.content + prompt = generator.calls[0][0].transcript + assert "\nFactual assistant\n" in prompt + assert f"\n{trace._repr_prompt_()}\n" in prompt + assert "\nParis.\n" in prompt assert ( "\nParis is the capital of France.\n" - in message.content + in prompt )