Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions libs/giskard-checks/src/giskard/checks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
AnswerRelevance,
BaseLLMCheck,
Conformity,
Correctness,
Groundedness,
LLMCheckResult,
LLMJudge,
Expand Down Expand Up @@ -100,6 +101,7 @@
"BaseLLMCheck",
"LLMCheckResult",
"Conformity",
"Correctness",
"Equals",
"NotEquals",
"LesserThan",
Expand Down
2 changes: 2 additions & 0 deletions libs/giskard-checks/src/giskard/checks/builtin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
AnswerRelevance,
BaseLLMCheck,
Conformity,
Correctness,
Groundedness,
LLMCheckResult,
LLMJudge,
Expand Down Expand Up @@ -44,6 +45,7 @@
"AnswerRelevance",
"Groundedness",
"Conformity",
"Correctness",
"LLMJudge",
"SemanticSimilarity",
"Toxicity",
Expand Down
2 changes: 2 additions & 0 deletions libs/giskard-checks/src/giskard/checks/judges/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from .answer_relevance import AnswerRelevance
from .base import BaseLLMCheck, LLMCheckResult
from .conformity import Conformity
from .correctness import Correctness
from .groundedness import Groundedness
from .judge import LLMJudge
from .toxicity import Toxicity
Expand All @@ -12,6 +13,7 @@
"BaseLLMCheck",
"LLMCheckResult",
"Conformity",
"Correctness",
"Groundedness",
"LLMJudge",
"Toxicity",
Expand Down
139 changes: 139 additions & 0 deletions libs/giskard-checks/src/giskard/checks/judges/correctness.py
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this a reference answer or a reference context?

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).
Comment on lines +24 to +30

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we add this everywhere?


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``).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change

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),
)
),
}
Comment on lines +114 to +137

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation of get_inputs wraps the results of provided_or_resolve in str(). If a value (such as description or reference_answer) is missing from both the check instance and the trace, provided_or_resolve returns None, and str(None) converts it to the literal string "None". This string is then passed to the LLM prompt, which can lead to confusing or incorrect evaluations (e.g., the LLM comparing the agent's answer against the word "None" instead of a real reference answer).

Additionally, since Correctness is a reference-based check, it is highly recommended to validate that a reference_answer has been successfully resolved before proceeding, as the check cannot function correctly without it.

        description = provided_or_resolve(
            trace,
            key=self.description_key,
            value=provide_not_none(self.description),
        )
        answer = provided_or_resolve(
            trace,
            key=self.answer_key,
            value=provide_not_none(self.answer),
        )
        reference_answer = provided_or_resolve(
            trace,
            key=self.reference_answer_key,
            value=provide_not_none(self.reference_answer),
        )

        if reference_answer is None:
            raise ValueError(
                "Correctness check failed: No reference answer provided or found in trace. "
                "Please provide 'reference_answer' or ensure it exists in the trace metadata."
            )

        return {
            "description": str(description) if description is not None else "",
            "conversation": trace,
            "answer": str(answer) if answer is not None else "",
            "reference_answer": str(reference_answer),
        }

Original file line number Diff line number Diff line change
@@ -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 <AGENT DESCRIPTION>...</AGENT DESCRIPTION>, <CONVERSATION>...</CONVERSATION>, <AGENT ANSWER>...</AGENT ANSWER>, and <REFERENCE ANSWER>...</REFERENCE ANSWER> indicate where each input is. Everything inside a marker belongs to that category.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason we don't use add _ in beteween the marker that consist of multiple words like AGENT_DESCRIPTION?


-------------------

<AGENT DESCRIPTION>
{{ description }}
</AGENT DESCRIPTION>

-------------------

<CONVERSATION>
{{ conversation }}
</CONVERSATION>

-------------------

<AGENT ANSWER>
{{ answer }}
</AGENT ANSWER>

-------------------

<REFERENCE ANSWER>
{{ reference_answer }}
</REFERENCE ANSWER>

-------------------

**Output Format:**
{{ _instr_output }}
107 changes: 107 additions & 0 deletions libs/giskard-checks/tests/builtin/test_correctness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import json
from typing import Any, override

from giskard.agents.chat import Message

Check failure on line 4 in libs/giskard-checks/tests/builtin/test_correctness.py

View workflow job for this annotation

GitHub Actions / lint

"Message" is unknown import symbol (reportAttributeAccessIssue)
from giskard.agents.generators._types import Response

Check failure on line 5 in libs/giskard-checks/tests/builtin/test_correctness.py

View workflow job for this annotation

GitHub Actions / lint

"Response" is unknown import symbol (reportAttributeAccessIssue)
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):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we create a shared version of this, perhaps?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it's planned.

It's not yet defined what should be inputs and outputs type (str, openai format, ... ) and how we want to represent tool calls, thinking, ...

def _repr_prompt_(self) -> str:
return "\n".join(
[
f"[user]: {interaction.inputs}\n[assistant]: {interaction.outputs}"
for interaction in self.interactions
]
)


class MockGenerator(BaseGenerator):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also this could perhaps be shared?

passed: bool
reason: str | None
calls: list[list[Message]] = Field(default_factory=list)

@override
async def _call_model(

Check failure on line 27 in libs/giskard-checks/tests/builtin/test_correctness.py

View workflow job for this annotation

GitHub Actions / lint

Method "_call_model" overrides class "BaseGenerator" in an incompatible manner   Parameter 2 type mismatch: base parameter is type "Sequence[ChatMessage]", override parameter is type "list[Unknown]"     "Sequence[ChatMessage]" is not assignable to "list[Unknown]" (reportIncompatibleMethodOverride)
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 (
"<AGENT DESCRIPTION>\nFactual assistant\n</AGENT DESCRIPTION>"
in message.content
)
assert (
f"<CONVERSATION>\n{trace._repr_prompt_()}\n</CONVERSATION>" in message.content
)
assert "<AGENT ANSWER>\nParis.\n</AGENT ANSWER>" in message.content
assert (
"<REFERENCE ANSWER>\nParis is the capital of France.\n</REFERENCE ANSWER>"
in message.content
)
Loading