-
Notifications
You must be signed in to change notification settings - Fork 1
Mark grader content-filter refusals as unscored #10
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
Changes from all commits
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 |
|---|---|---|
|
|
@@ -6,7 +6,12 @@ | |
| from pathlib import Path | ||
| from typing import Any, cast | ||
|
|
||
| from inspect_ai.model import GenerateConfig, ResponseSchema, get_model | ||
| from inspect_ai.model import ( | ||
| GenerateConfig, | ||
| ModelOutput, | ||
| ResponseSchema, | ||
| get_model, | ||
| ) | ||
| from inspect_ai.scorer import ( | ||
| CORRECT, | ||
| INCORRECT, | ||
|
|
@@ -27,6 +32,10 @@ | |
| DEFAULT_GRADER_MODEL = "anthropic/claude-sonnet-4-5" | ||
| GRADER_ROLE = "grader" | ||
|
|
||
| # Content moderation can block a grader response non-deterministically, so a | ||
| # blocked call might clear on a retry. Cap the attempts before giving up. | ||
| MAX_GRADER_ATTEMPTS = 3 | ||
|
|
||
| JUDGE_VERDICT_CORRECT = "correct" | ||
| JUDGE_VERDICT_INCORRECT = "incorrect" | ||
| JUDGE_VERDICT_UNSURE = "unsure" | ||
|
|
@@ -59,24 +68,49 @@ def _judge_score(prompt_template: str) -> Scorer: | |
| name="evaluation_result", json_schema=json_schema(EvaluationResult) | ||
| ) | ||
|
|
||
| async def call_grader_with_retry(prompt: str) -> ModelOutput | None: | ||
| result: ModelOutput | None = None | ||
| grader = get_model(role=GRADER_ROLE, default=DEFAULT_GRADER_MODEL) | ||
| attempts = 0 | ||
| while attempts < MAX_GRADER_ATTEMPTS: | ||
| attempts += 1 | ||
| result = await grader.generate( | ||
| prompt, | ||
| config=GenerateConfig(temperature=0.0, response_schema=response_schema), | ||
| ) | ||
| if not _grader_refused(result): | ||
| break | ||
| return result | ||
|
|
||
| async def score(state: TaskState, target: Target) -> Score: | ||
| answer = state.output.completion.strip() | ||
| if not answer: | ||
| return Score( | ||
| value=INCORRECT, answer="", explanation="No answer was produced." | ||
| ) | ||
|
|
||
| grader = get_model(role=GRADER_ROLE, default=DEFAULT_GRADER_MODEL) | ||
|
|
||
| prompt = prompt_template.format( | ||
| question=state.input_text, | ||
| correct_answer=target.text, | ||
| answer=answer, | ||
| ) | ||
| result = await grader.generate( | ||
| prompt, | ||
| config=GenerateConfig(temperature=0.0, response_schema=response_schema), | ||
| ) | ||
|
|
||
| result = await call_grader_with_retry(prompt) | ||
|
|
||
| if result is None or _grader_refused(result): | ||
| stop_reason = getattr(result, "stop_reason", None) | ||
| return Score.unscored( | ||
| answer=answer, | ||
| explanation=( | ||
| f"Grader returned no gradeable response " | ||
| f"attempt(s) (stop_reason={stop_reason}); sample left unscored." | ||
| ), | ||
| metadata={ | ||
| "verdict": None, | ||
| "verdict_source": "refusal", | ||
| "grader_stop_reason": stop_reason, | ||
| }, | ||
| ) | ||
|
|
||
| try: | ||
| evaluation = EvaluationResult.model_validate_json(result.completion) | ||
|
|
@@ -99,6 +133,17 @@ async def score(state: TaskState, target: Target) -> Score: | |
| return score | ||
|
|
||
|
|
||
| def _grader_refused(result: ModelOutput) -> bool: | ||
| """Return True when the grader produced no gradeable response. | ||
|
|
||
| A ``content_filter`` stop reason or an empty completion means moderation | ||
| blocked the grader (or it returned nothing), not that the answer is wrong. | ||
| """ | ||
| if result.stop_reason == "content_filter": | ||
|
Collaborator
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. 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. thats so interesting that the content filter blocked the grading stage but not the actual question answering stage ahah
Collaborator
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. I guess the different provider have different content filters: the model under test is gpt 5.2, whereas the judge is Sonnet 4.5 |
||
| return True | ||
| return not (result.completion or "").strip() | ||
|
|
||
|
|
||
| @scorer(metrics=[accuracy(), stderr()]) | ||
| def semantic_judge_scorer() -> Scorer: | ||
| """Grade an open-ended answer against the reference using a judge model.""" | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| import subprocess | ||
| import sys | ||
| import textwrap | ||
|
|
||
|
|
||
| def test_package_imports_without_optional_labbench2_dependency() -> None: | ||
|
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. Not relevant to this PR - but I wonder if this optional dependency is a weakness of the template... it seems like it added more confusion than necessary for most people who would only implement one eval per repo...
Collaborator
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. If there is only a single eval in the repo, users can make the deps required. Just add them in the main dependencies list here. |
||
| # given a fresh interpreter where the optional ``labbench2`` extra (which | ||
| # provides the ``evals`` / ``labbench2`` modules) cannot be imported | ||
| script = textwrap.dedent( | ||
| """ | ||
| import sys | ||
|
|
||
| class _Blocker: | ||
| def find_spec(self, name, path=None, target=None): | ||
| if name.split(".")[0] in {"evals", "labbench2"}: | ||
| raise ModuleNotFoundError(name) | ||
| return None | ||
|
|
||
| sys.meta_path.insert(0, _Blocker()) | ||
|
|
||
| # Importing the package is what Inspect does for entry-point task | ||
| # discovery; it must not pull in the optional dependency. | ||
| import lab_bench_2 | ||
| from lab_bench_2 import lab_bench_2 as task, SUPPORTED_TAGS | ||
|
|
||
| assert callable(task) | ||
| assert "litqa3" in SUPPORTED_TAGS | ||
| assert "evals" not in sys.modules | ||
| assert "labbench2" not in sys.modules | ||
| """ | ||
| ) | ||
|
|
||
| # when the package is imported in that interpreter | ||
| result = subprocess.run( | ||
| [sys.executable, "-c", script], | ||
| capture_output=True, | ||
| text=True, | ||
| check=False, | ||
| ) | ||
|
|
||
| # then discovery succeeds without the optional dependency installed | ||
| assert result.returncode == 0, result.stderr | ||
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.
This is the first time I've seen the Score.unscored option - v cool
I like that it enables future scoring and really highlights that the issue is with the scoring (and make re-scoring easier https://inspect.aisi.org.uk/scoring-workflow.html)
The pattern I previously knew of is to raise an error and rely on --retry-on-error (https://inspect.aisi.org.uk/options.html#errors-and-limits). The benefit is the retry is built in, but the ^ re-scoring options aren't (get logged as errors after x retries))
I wonder if there is a neat fix combining the two in inspect ai! Esp as LLM judges are getting more popular as scoring options.