-
-
Notifications
You must be signed in to change notification settings - Fork 505
feat(giskard-checks): add Correctness LLM judge check #2350
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
c89b951
79e5a0b
0852585
06e8c8a
cebbaa9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||||
| 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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``). | ||||
|
|
||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The current implementation of Additionally, since 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. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 }} | ||
| 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 | ||
| 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): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can we create a shared version of this, perhaps?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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( | ||
| 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 | ||
| ) | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
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?