Files
wehub-resource-sync 542cfa195c
CI / Frontend build (push) Failing after 9m6s
CI / Plugin validate (push) Failing after 9m27s
CI / Python lint (push) Failing after 16m1s
CI / Tests (push) Successful in 18m0s
Deploy / deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:27 +08:00

239 lines
7.9 KiB
Python

"""Self-contained LLM-as-judge grader for the PixelRAG reproduction.
Migrated from the paper's evaluation/worldvqa_eval/worldvqa_eval.py + evaluate.py
(the encyclopedic_vqa / mmsearch / worldvqa path) so the eval pipeline does not
depend on the old dr-agent (Vis-RAG) repo. Behaviour is byte-faithful to the
paper grader:
- Judge prompt = JUDGE_WORLDQA_PROMPT_EN (verbatim from MoonshotAI/WorldVQA),
loaded from eval/repro_assets/judge_worldvqa_prompt.txt.
- Ground truth:
* encyclopedic_vqa -> "Any of: " + " | ".join(reference_list) (ANY match = correct)
* mmsearch / worldvqa -> gt_answer (single string)
- The model response has <think>...</think> stripped before judging.
- Judge model gpt-4.1-2025-04-14, temperature=0; verdict parsed from a
`Label: Correct|Incorrect|Unattempted` line.
- score = #Correct / N.
CLI:
python -m lib.grader <task> <responses.jsonl> [--grader-model gpt-4.1-2025-04-14]
Requires OPENAI_API_KEY (+ optional OPENAI_BASE_URL) in the environment.
"""
import argparse
import asyncio
import json
import os
import re
import string
from pathlib import Path
_ASSETS = Path(__file__).resolve().parent.parent / "repro_assets"
JUDGE_WORLDQA_PROMPT_EN = (_ASSETS / "judge_worldvqa_prompt.txt").read_text()
SIMPLEQA_GRADER_TEMPLATE = (_ASSETS / "simpleqa_grader_template.txt").read_text()
# Which grader each task uses (matches paper scripts/evaluate.py dispatch).
WORLDVQA_TASKS = {
"encyclopedic_vqa",
"mmsearch",
"worldvqa",
"factualvqa",
"webqa",
"multimodalqa",
}
EXACT_MATCH_TASKS = {"nq", "nq_tables", "triviaqa"}
SIMPLEQA_TASKS = {"simpleqa", "simpleqa_verified"}
DEFAULT_GRADER_MODEL = "gpt-4.1-2025-04-14"
# Match the paper grader sampler (scripts/evaluate.py -> ChatCompletionSampler):
# system message "You are a helpful assistant.", temperature=0, max_tokens=1000, seed=42.
GRADER_SYSTEM_MESSAGE = "You are a helpful assistant."
GRADER_MAX_TOKENS = 1000
GRADER_SEED = 42
def strip_think(text: str) -> str:
# Verbatim from paper worldvqa_eval.strip_think_tags.
if text is None:
return ""
if "<think>" in text and "</think>" in text:
return text.split("</think>")[-1].strip()
elif "think>" in text:
return text.split("think>")[-1].strip()
return text
def build_ground_truth(task: str, original_data: dict) -> str:
"""Match evaluate.py convert_to_evaluate_format."""
if task == "encyclopedic_vqa":
refs = original_data.get("reference_list") or []
if refs:
return "Any of: " + " | ".join(refs)
return original_data.get("answer", "") or original_data.get("gt_answer", "")
# mmsearch / worldvqa / simplevqa / factualvqa
return original_data.get("gt_answer", "") or original_data.get("answer", "")
def parse_label(judge_text: str) -> str:
m = re.search(
r"Label:\s*(Correct|Incorrect|Unattempted)", judge_text, re.IGNORECASE
)
if m:
return m.group(1).lower()
tl = judge_text.lower()
if "incorrect" in tl:
return "incorrect"
if "unattempted" in tl:
return "unattempted"
if "correct" in tl:
return "correct"
return "incorrect"
# ---------------------------------------------------------------------------
# NQ / NQ-Tables exact-match (verbatim from short_form_qa_eval.short_form_eval)
# ---------------------------------------------------------------------------
def _normalize_text(s: str) -> str:
s = re.sub(
r"\b(a|an|the)\b",
" ",
s.lower().translate(str.maketrans("", "", string.punctuation)),
)
return " ".join(s.split())
def is_exact_match(prediction: str, golds) -> bool:
prediction = (prediction or "").replace("Exact Answer: ", "").strip()
pred_norm = _normalize_text(prediction)
return any(_normalize_text(str(g)) == pred_norm for g in golds)
def _golds_for(task: str, od: dict):
if task in EXACT_MATCH_TASKS:
g = (
od.get("answers")
or od.get("reference_list")
or od.get("answer")
or od.get("gt_answer")
)
return g if isinstance(g, list) else [g]
return None
def grade_exact_match(path: str) -> dict:
rows = [json.loads(l) for l in open(path)]
c = 0
for d in rows:
golds = _golds_for("nq", d.get("original_data", {}))
if is_exact_match(strip_think(d.get("final_response")), golds):
c += 1
n = len(rows)
return {
"task": "exact_match",
"file": path,
"n": n,
"correct": c,
"incorrect": n - c,
"unattempted": 0,
"errors": 0,
"score": c / n if n else 0.0,
}
async def grade_file(
task: str,
path: str,
grader_model: str = DEFAULT_GRADER_MODEL,
concurrency: int = 16,
) -> dict:
if task in EXACT_MATCH_TASKS:
return grade_exact_match(path)
from openai import AsyncOpenAI
client = AsyncOpenAI(
api_key=os.environ["OPENAI_API_KEY"], base_url=os.environ.get("OPENAI_BASE_URL")
)
rows = [json.loads(l) for l in open(path)]
sem = asyncio.Semaphore(concurrency)
labels = [None] * len(rows)
is_sqa = task in SIMPLEQA_TASKS
async def judge(i, d):
od = d.get("original_data", {})
answer = strip_think(d.get("final_response"))
if is_sqa:
target = od.get("answer", "") or od.get("gt_answer", "")
prompt = SIMPLEQA_GRADER_TEMPLATE.format(
question=d.get("problem", ""), target=target, predicted_answer=answer
)
else:
gt = build_ground_truth(task, od)
prompt = JUDGE_WORLDQA_PROMPT_EN.format(
question=d.get("problem", ""),
model_answer=answer,
ground_truth_answer=gt,
)
async with sem:
try:
r = await client.chat.completions.create(
model=grader_model,
temperature=0,
max_tokens=GRADER_MAX_TOKENS,
seed=GRADER_SEED,
messages=[
{"role": "system", "content": GRADER_SYSTEM_MESSAGE},
{"role": "user", "content": prompt},
],
)
out = r.choices[0].message.content
if is_sqa:
m = re.search(r"(A|B|C)", out or "")
letter = m.group(0) if m else "C"
labels[i] = {"A": "correct", "B": "incorrect", "C": "unattempted"}[
letter
]
else:
labels[i] = parse_label(out)
except Exception as e:
labels[i] = ("__error__", str(e))
await asyncio.gather(*[judge(i, d) for i, d in enumerate(rows)])
errs = [l for l in labels if isinstance(l, tuple)]
verdicts = [l for l in labels if isinstance(l, str)]
n = len(verdicts)
c = verdicts.count("correct")
inc = verdicts.count("incorrect")
una = verdicts.count("unattempted")
return {
"task": task,
"file": path,
"n": n,
"correct": c,
"incorrect": inc,
"unattempted": una,
"errors": len(errs),
"score": c / n if n else 0.0,
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("task", help="encyclopedic_vqa | mmsearch | worldvqa | ...")
ap.add_argument("jsonl", help="responses jsonl from run_bench.py")
ap.add_argument("--grader-model", default=DEFAULT_GRADER_MODEL)
ap.add_argument("--concurrency", type=int, default=16)
args = ap.parse_args()
res = asyncio.run(
grade_file(args.task, args.jsonl, args.grader_model, args.concurrency)
)
print(
f"{Path(res['file']).name}: {res['correct']}/{res['n']} = {res['score']:.4f} "
f"(C={res['correct']} I={res['incorrect']} U={res['unattempted']} err={res['errors']})"
)
print(f"Score: {res['score']:.3f}")
if __name__ == "__main__":
main()