Skip to content

Commit 2dc14ca

Browse files
XiaoBoAIcursoragent
andcommitted
feat: add Reference Hallucination Arena cookbook implementation
Add the complete ref_hallucination_arena cookbook with pipeline, verifiers, collectors, scoring, and reporting modules for evaluating LLM reference recommendation accuracy against Crossref, PubMed, arXiv, and DBLP. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 20b3c41 commit 2dc14ca

22 files changed

Lines changed: 3938 additions & 0 deletions
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# -*- coding: utf-8 -*-
2+
"""CLI entry point for Reference Hallucination Arena.
3+
4+
Usage:
5+
python -m cookbooks.ref_hallucination_arena --config config.yaml
6+
python -m cookbooks.ref_hallucination_arena --config config.yaml --save
7+
python -m cookbooks.ref_hallucination_arena --config config.yaml --fresh
8+
"""
9+
10+
import asyncio
11+
from pathlib import Path
12+
from typing import Optional
13+
14+
import fire
15+
from loguru import logger
16+
17+
from cookbooks.ref_hallucination_arena.pipeline import RefArenaPipeline
18+
from cookbooks.ref_hallucination_arena.schema import load_config
19+
20+
21+
async def _run_evaluation(
22+
config_path: str,
23+
output_dir: Optional[str] = None,
24+
save: bool = False,
25+
resume: bool = True,
26+
) -> None:
27+
"""Run the evaluation pipeline."""
28+
config = load_config(config_path)
29+
30+
if output_dir:
31+
config.output.output_dir = output_dir
32+
33+
pipeline = RefArenaPipeline(config=config, resume=resume)
34+
result = await pipeline.evaluate()
35+
36+
if save:
37+
pipeline.save_results(result)
38+
39+
40+
def main(
41+
config: str,
42+
output_dir: Optional[str] = None,
43+
save: bool = False,
44+
fresh: bool = False,
45+
) -> None:
46+
"""Reference Hallucination Arena CLI.
47+
48+
Evaluate LLM reference recommendation capabilities by verifying
49+
recommended papers against Crossref, PubMed, arXiv, and DBLP.
50+
51+
Args:
52+
config: Path to YAML configuration file.
53+
output_dir: Output directory for results (overrides config).
54+
save: Whether to save results to file.
55+
fresh: Start fresh, ignore any existing checkpoint.
56+
57+
Examples:
58+
# Normal run (auto-resumes from checkpoint)
59+
python -m cookbooks.ref_hallucination_arena --config config.yaml --save
60+
61+
# Start fresh
62+
python -m cookbooks.ref_hallucination_arena --config config.yaml --fresh --save
63+
"""
64+
config_path = Path(config)
65+
if not config_path.exists():
66+
logger.error(f"Config file not found: {config}")
67+
return
68+
69+
if fresh:
70+
logger.info("Starting fresh (ignoring checkpoint)")
71+
loaded_config = load_config(str(config_path))
72+
effective_output_dir = output_dir or loaded_config.output.output_dir
73+
from cookbooks.ref_hallucination_arena.pipeline import CheckpointManager
74+
75+
CheckpointManager(effective_output_dir).clear()
76+
else:
77+
logger.info("Resume mode enabled")
78+
79+
logger.info(f"Starting Reference Hallucination Arena with config: {config}")
80+
81+
asyncio.run(
82+
_run_evaluation(
83+
str(config_path),
84+
output_dir,
85+
save,
86+
resume=not fresh,
87+
)
88+
)
89+
90+
91+
if __name__ == "__main__":
92+
fire.Fire(main)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# -*- coding: utf-8 -*-
2+
"""Data collectors for Reference Hallucination Arena."""
3+
4+
from cookbooks.ref_hallucination_arena.collectors.bib_extractor import BibExtractor
5+
from cookbooks.ref_hallucination_arena.collectors.response_collector import ResponseCollector
6+
7+
__all__ = ["BibExtractor", "ResponseCollector"]
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
# -*- coding: utf-8 -*-
2+
"""Extract BibTeX references from free-text model responses."""
3+
4+
import re
5+
from typing import List, Optional
6+
7+
from loguru import logger
8+
9+
from cookbooks.ref_hallucination_arena.schema import Reference
10+
11+
12+
class BibExtractor:
13+
"""Extract BibTeX entries from model responses.
14+
15+
Strategies (tried in order):
16+
1. Extract content inside ```bib / ```bibtex code fences.
17+
2. Extract standalone @type{...} entries scattered in the text.
18+
3. Fallback: try to parse structured plain-text references.
19+
"""
20+
21+
# Matches ```bib or ```bibtex fenced code blocks
22+
_FENCE_PATTERN = re.compile(
23+
r"```(?:bib(?:tex)?)\s*\n(.*?)```",
24+
re.DOTALL | re.IGNORECASE,
25+
)
26+
27+
# Matches a full BibTeX entry: @type{key, ... }
28+
# Uses brace-counting to handle nested braces correctly
29+
_ENTRY_START_PATTERN = re.compile(
30+
r"@(\w+)\s*\{\s*([^,\s]*)\s*,",
31+
re.IGNORECASE,
32+
)
33+
34+
def extract(self, response_text: str) -> List[Reference]:
35+
"""Extract references from a model response.
36+
37+
Args:
38+
response_text: Raw text response from the model.
39+
40+
Returns:
41+
List of extracted Reference objects.
42+
"""
43+
if not response_text:
44+
return []
45+
46+
# Strategy 1: fenced code blocks
47+
fenced_content = self._extract_fenced(response_text)
48+
if fenced_content:
49+
refs = self._parse_bibtex(fenced_content)
50+
if refs:
51+
return refs
52+
53+
# Strategy 2: standalone entries in text
54+
refs = self._parse_bibtex(response_text)
55+
if refs:
56+
return refs
57+
58+
# Strategy 3: plain-text fallback (numbered references)
59+
return self._parse_plain_text(response_text)
60+
61+
def _extract_fenced(self, text: str) -> str:
62+
"""Extract content from ```bib/bibtex fenced blocks."""
63+
blocks = self._FENCE_PATTERN.findall(text)
64+
if blocks:
65+
return "\n\n".join(blocks)
66+
return ""
67+
68+
def _parse_bibtex(self, text: str) -> List[Reference]:
69+
"""Parse BibTeX entries using brace-counting for robustness."""
70+
refs = []
71+
72+
for match in self._ENTRY_START_PATTERN.finditer(text):
73+
entry_type = match.group(1).lower()
74+
key = match.group(2).strip()
75+
76+
# Find the matching closing brace via counting
77+
start = match.start()
78+
brace_start = text.index("{", start)
79+
fields_str = self._extract_braced_content(text, brace_start)
80+
if fields_str is None:
81+
continue
82+
83+
ref = self._parse_fields(key, entry_type, fields_str)
84+
if ref:
85+
refs.append(ref)
86+
87+
return refs
88+
89+
def _extract_braced_content(self, text: str, open_pos: int) -> Optional[str]:
90+
"""Extract content between matched braces starting at open_pos."""
91+
depth = 0
92+
for i in range(open_pos, len(text)):
93+
if text[i] == "{":
94+
depth += 1
95+
elif text[i] == "}":
96+
depth -= 1
97+
if depth == 0:
98+
return text[open_pos + 1 : i]
99+
return None # unmatched
100+
101+
def _parse_fields(self, key: str, entry_type: str, fields_str: str) -> Optional[Reference]:
102+
"""Parse individual fields from BibTeX entry body."""
103+
104+
def extract_field(name: str) -> Optional[str]:
105+
# Match field = {value} or field = "value"
106+
pattern = rf'{name}\s*=\s*[{{"](.*?)[}}"]'
107+
m = re.search(pattern, fields_str, re.IGNORECASE | re.DOTALL)
108+
return m.group(1).strip() if m else None
109+
110+
title = extract_field("title")
111+
if not title:
112+
return None
113+
114+
# Extract arXiv ID
115+
arxiv_id = None
116+
journal = extract_field("journal") or extract_field("booktitle") or ""
117+
eprint = extract_field("eprint")
118+
if eprint:
119+
arxiv_id = eprint
120+
elif "arxiv" in journal.lower():
121+
arxiv_match = re.search(r"(\d{4}\.\d{4,5})", journal)
122+
if arxiv_match:
123+
arxiv_id = arxiv_match.group(1)
124+
125+
# Extract PMID from note or url
126+
pmid = None
127+
note = extract_field("note") or ""
128+
url = extract_field("url") or ""
129+
pmid_match = re.search(r"(?:PMID|pmid)[:\s]*(\d+)", note + " " + url)
130+
if pmid_match:
131+
pmid = pmid_match.group(1)
132+
133+
return Reference(
134+
key=key,
135+
title=title,
136+
authors=extract_field("author"),
137+
year=extract_field("year"),
138+
journal=journal,
139+
doi=extract_field("doi"),
140+
arxiv_id=arxiv_id,
141+
pmid=pmid,
142+
entry_type=entry_type,
143+
)
144+
145+
def _parse_plain_text(self, text: str) -> List[Reference]:
146+
"""Fallback: parse numbered plain-text references.
147+
148+
Handles patterns like:
149+
1. Author et al. (2023). "Title". Journal.
150+
[1] Author et al., "Title", Journal, 2023.
151+
"""
152+
refs = []
153+
154+
# Pattern: numbered reference with quoted title
155+
patterns = [
156+
# "1. Authors (Year). Title. Journal."
157+
re.compile(
158+
r"(?:^|\n)\s*(?:\d+[\.\)]\s*|[\[\(]\d+[\]\)]\s*)"
159+
r"(.+?)\s*[\(\[]?(\d{4})[\)\]]?\s*[\.\,]\s*"
160+
r'["\u201c](.+?)["\u201d]',
161+
re.MULTILINE,
162+
),
163+
# Simpler: "Title" (Year)
164+
re.compile(
165+
r'["\u201c](.+?)["\u201d]\s*[\(\[]?(\d{4})[\)\]]?',
166+
),
167+
]
168+
169+
seen_titles = set()
170+
for pattern in patterns:
171+
for m in pattern.finditer(text):
172+
groups = m.groups()
173+
if len(groups) >= 3:
174+
authors, year, title = groups[0], groups[1], groups[2]
175+
elif len(groups) >= 2:
176+
title, year = groups[0], groups[1]
177+
authors = None
178+
else:
179+
continue
180+
181+
title_lower = title.strip().lower()
182+
if title_lower in seen_titles or len(title_lower) < 10:
183+
continue
184+
seen_titles.add(title_lower)
185+
186+
refs.append(
187+
Reference(
188+
key=f"ref_{len(refs)+1}",
189+
title=title.strip(),
190+
authors=authors.strip() if authors else None,
191+
year=year.strip(),
192+
)
193+
)
194+
195+
return refs

0 commit comments

Comments
 (0)