"""OSS Benchmark Evaluations for HTML Content Extraction. This module evaluates HTMLExtractor against established open-source benchmarks: 1. **Scrapinghub Article Extraction Benchmark** (HuggingFace: allenai/scrapinghub-article-extraction-benchmark) - 181 HTML pages with ground truth article bodies - Measures extraction F1 score (precision, recall) - trafilatura baseline: 0.958 F1 2. **WebSRC Reading Comprehension** (HuggingFace: X-LANCE/WebSRC_v1.0) - 400K Q&A pairs on 6.4K web pages with HTML - Measures whether extraction preserves QA accuracy - Tests: Original HTML vs Extracted content → same answer? The goal is to prove that HTMLExtractor does NOT lose accuracy while achieving significant compression by removing structural noise. References: - https://github.com/scrapinghub/article-extraction-benchmark - https://huggingface.co/datasets/allenai/scrapinghub-article-extraction-benchmark - https://huggingface.co/datasets/X-LANCE/WebSRC_v1.0 """ from __future__ import annotations import logging import re from collections import Counter from dataclasses import dataclass, field from typing import Any logger = logging.getLogger(__name__) # ============================================================================ # Metrics (from established NLP evaluation) # ============================================================================ def tokenize(text: str) -> list[str]: """Simple word tokenization for F1 calculation.""" return re.findall(r"\b\w+\b", text.lower()) def compute_f1(prediction: str, ground_truth: str) -> tuple[float, float, float]: """Compute token-level precision, recall, F1. This is the standard metric used in article extraction benchmarks. Returns: Tuple of (precision, recall, f1) """ pred_tokens = tokenize(prediction) truth_tokens = tokenize(ground_truth) if not pred_tokens or not truth_tokens: return 0.0, 0.0, 0.0 pred_counter = Counter(pred_tokens) truth_counter = Counter(truth_tokens) common = sum((pred_counter & truth_counter).values()) if common == 0: return 0.0, 0.0, 0.0 precision = common / len(pred_tokens) recall = common / len(truth_tokens) f1 = 2 * precision * recall / (precision + recall) return precision, recall, f1 def compute_exact_match(prediction: str, ground_truth: str) -> bool: """Check if answers match after normalization.""" pred_norm = " ".join(tokenize(prediction)) truth_norm = " ".join(tokenize(ground_truth)) return pred_norm == truth_norm # ============================================================================ # Scrapinghub Article Extraction Benchmark # ============================================================================ @dataclass class ExtractionBenchmarkResult: """Result from Scrapinghub article extraction benchmark.""" total_samples: int avg_precision: float avg_recall: float avg_f1: float avg_compression_ratio: float # Per-sample details sample_results: list[dict[str, Any]] = field(default_factory=list) # Comparison with baseline baseline_f1: float = 0.958 # trafilatura's score on this benchmark @property def matches_baseline(self) -> bool: """True if our F1 is within 0.02 of baseline.""" return abs(self.avg_f1 - self.baseline_f1) < 0.02 @property def beats_baseline(self) -> bool: """True if our F1 exceeds baseline.""" return self.avg_f1 > self.baseline_f1 def summary(self) -> dict[str, Any]: return { "total_samples": self.total_samples, "avg_precision": round(self.avg_precision, 4), "avg_recall": round(self.avg_recall, 4), "avg_f1": round(self.avg_f1, 4), "baseline_f1": self.baseline_f1, "matches_baseline": self.matches_baseline, "avg_compression_ratio": round(self.avg_compression_ratio, 4), } def evaluate_scrapinghub_benchmark( extractor: Any = None, max_samples: int | None = None, ) -> ExtractionBenchmarkResult: """Evaluate HTMLExtractor on Scrapinghub Article Extraction Benchmark. This benchmark measures how well we extract article body text from HTML. The established baseline (trafilatura) achieves 0.958 F1. Args: extractor: HTMLExtractor instance (creates one if None) max_samples: Limit number of samples (for quick testing) Returns: ExtractionBenchmarkResult with precision, recall, F1 scores Example: result = evaluate_scrapinghub_benchmark(max_samples=50) print(f"F1: {result.avg_f1:.3f} (baseline: {result.baseline_f1})") """ try: from datasets import load_dataset except ImportError: raise ImportError( "HuggingFace datasets required. Install with: pip install datasets" ) from None if extractor is None: from headroom.transforms.html_extractor import HTMLExtractor extractor = HTMLExtractor() # Load the benchmark dataset logger.info("Loading Scrapinghub article extraction benchmark...") dataset = load_dataset("allenai/scrapinghub-article-extraction-benchmark") samples = dataset["train"] if max_samples: samples = samples.select(range(min(max_samples, len(samples)))) logger.info(f"Evaluating {len(samples)} samples...") precisions = [] recalls = [] f1_scores = [] compression_ratios = [] sample_results = [] for i, sample in enumerate(samples): html = sample["html"] ground_truth = sample["articleBody"] url = sample.get("url") # Extract using our extractor result = extractor.extract(html, url=url) extracted = result.extracted # Compute metrics precision, recall, f1 = compute_f1(extracted, ground_truth) precisions.append(precision) recalls.append(recall) f1_scores.append(f1) compression_ratios.append(result.compression_ratio) sample_results.append( { "url": url, "precision": precision, "recall": recall, "f1": f1, "compression_ratio": result.compression_ratio, "html_length": len(html), "extracted_length": len(extracted), "ground_truth_length": len(ground_truth), } ) if (i + 1) % 20 == 0: logger.info(f" Processed {i + 1}/{len(samples)} samples") return ExtractionBenchmarkResult( total_samples=len(samples), avg_precision=sum(precisions) / len(precisions), avg_recall=sum(recalls) / len(recalls), avg_f1=sum(f1_scores) / len(f1_scores), avg_compression_ratio=sum(compression_ratios) / len(compression_ratios), sample_results=sample_results, ) # ============================================================================ # QA Accuracy Preservation Evaluation # ============================================================================ @dataclass class QAAccuracyResult: """Result from QA accuracy preservation evaluation.""" total_questions: int # Accuracy on different inputs accuracy_original_html: float # Answer from original HTML accuracy_extracted: float # Answer from extracted content # The key metric: did extraction preserve accuracy? accuracy_preserved: bool # True if extracted >= original - 0.02 # F1 scores avg_f1_original: float avg_f1_extracted: float # Exact match rates exact_match_original: float exact_match_extracted: float # Details question_results: list[dict[str, Any]] = field(default_factory=list) def summary(self) -> dict[str, Any]: return { "total_questions": self.total_questions, "accuracy_original_html": round(self.accuracy_original_html, 4), "accuracy_extracted": round(self.accuracy_extracted, 4), "accuracy_preserved": self.accuracy_preserved, "accuracy_delta": round(self.accuracy_extracted - self.accuracy_original_html, 4), "avg_f1_original": round(self.avg_f1_original, 4), "avg_f1_extracted": round(self.avg_f1_extracted, 4), } def evaluate_qa_accuracy_preservation( answer_fn: Any, extractor: Any = None, max_questions: int = 100, dataset_name: str = "squad", ) -> QAAccuracyResult: """Evaluate whether HTML extraction preserves QA accuracy. This test verifies that LLMs can answer questions equally well (or better) from extracted content vs original HTML. Args: answer_fn: Function(context, question) -> answer string extractor: HTMLExtractor instance max_questions: Number of questions to evaluate dataset_name: Which dataset to use ("squad" or "hotpotqa") Returns: QAAccuracyResult showing whether accuracy is preserved """ try: from datasets import load_dataset except ImportError: raise ImportError("HuggingFace datasets required") from None if extractor is None: from headroom.transforms.html_extractor import HTMLExtractor extractor = HTMLExtractor() # Load QA dataset logger.info(f"Loading {dataset_name} dataset...") if dataset_name == "squad": dataset = load_dataset("rajpurkar/squad_v2", split="validation") elif dataset_name == "hotpotqa": dataset = load_dataset("hotpotqa/hotpot_qa", "fullwiki", split="validation") else: raise ValueError(f"Unknown dataset: {dataset_name}") # Select subset samples = dataset.select(range(min(max_questions, len(dataset)))) logger.info(f"Evaluating {len(samples)} questions...") f1_original = [] f1_extracted = [] em_original = [] em_extracted = [] question_results = [] for i, sample in enumerate(samples): # Get question and context question = sample["question"] if dataset_name == "squad": context = sample["context"] answers = sample["answers"]["text"] ground_truth = answers[0] if answers else "" else: # hotpotqa # Combine supporting facts into context context = " ".join(sample.get("context", {}).get("sentences", [""])) ground_truth = sample.get("answer", "") if not context or not ground_truth: continue # Wrap context in minimal HTML structure for realistic test html_context = f""" Document

Content

{context}
""" # Extract content result = extractor.extract(html_context) extracted_context = result.extracted # Get answers from both try: answer_original = answer_fn(html_context, question) answer_extracted = answer_fn(extracted_context, question) except Exception as e: logger.warning(f"Answer generation failed: {e}") continue # Compute metrics _, _, f1_orig = compute_f1(answer_original, ground_truth) _, _, f1_ext = compute_f1(answer_extracted, ground_truth) em_orig = compute_exact_match(answer_original, ground_truth) em_ext = compute_exact_match(answer_extracted, ground_truth) f1_original.append(f1_orig) f1_extracted.append(f1_ext) em_original.append(1.0 if em_orig else 0.0) em_extracted.append(1.0 if em_ext else 0.0) question_results.append( { "question": question, "ground_truth": ground_truth, "answer_original": answer_original, "answer_extracted": answer_extracted, "f1_original": f1_orig, "f1_extracted": f1_ext, } ) if (i + 1) % 10 == 0: logger.info(f" Processed {i + 1}/{len(samples)} questions") if not f1_original: raise ValueError("No valid samples processed") avg_f1_orig = sum(f1_original) / len(f1_original) avg_f1_ext = sum(f1_extracted) / len(f1_extracted) avg_em_orig = sum(em_original) / len(em_original) avg_em_ext = sum(em_extracted) / len(em_extracted) # Accuracy is preserved if extracted is within 2% of original accuracy_preserved = avg_f1_ext >= avg_f1_orig - 0.02 return QAAccuracyResult( total_questions=len(f1_original), accuracy_original_html=avg_f1_orig, accuracy_extracted=avg_f1_ext, accuracy_preserved=accuracy_preserved, avg_f1_original=avg_f1_orig, avg_f1_extracted=avg_f1_ext, exact_match_original=avg_em_orig, exact_match_extracted=avg_em_ext, question_results=question_results, ) # ============================================================================ # Combined Evaluation Runner # ============================================================================ @dataclass class HTMLExtractorBenchmarkSuite: """Complete benchmark suite results.""" extraction_result: ExtractionBenchmarkResult | None = None qa_result: QAAccuracyResult | None = None @property def all_passed(self) -> bool: """True if all benchmarks pass.""" passed = True if self.extraction_result: # F1 should be within 0.05 of baseline (0.958) passed = passed and self.extraction_result.avg_f1 >= 0.90 if self.qa_result: # Accuracy should be preserved passed = passed and self.qa_result.accuracy_preserved return passed def summary(self) -> dict[str, Any]: result: dict[str, Any] = {"all_passed": self.all_passed} if self.extraction_result: result["extraction"] = self.extraction_result.summary() if self.qa_result: result["qa_accuracy"] = self.qa_result.summary() return result def run_full_benchmark_suite( extractor: Any = None, answer_fn: Any = None, extraction_samples: int = 50, qa_questions: int = 50, ) -> HTMLExtractorBenchmarkSuite: """Run the complete HTML extraction benchmark suite. Args: extractor: HTMLExtractor instance (creates one if None) answer_fn: Function for QA evaluation (skips QA if None) extraction_samples: Number of extraction benchmark samples qa_questions: Number of QA questions Returns: HTMLExtractorBenchmarkSuite with all results """ if extractor is None: from headroom.transforms.html_extractor import HTMLExtractor extractor = HTMLExtractor() suite = HTMLExtractorBenchmarkSuite() # Run extraction benchmark logger.info("=" * 50) logger.info("Running Scrapinghub Article Extraction Benchmark") logger.info("=" * 50) try: suite.extraction_result = evaluate_scrapinghub_benchmark( extractor=extractor, max_samples=extraction_samples, ) logger.info(f"Extraction F1: {suite.extraction_result.avg_f1:.3f}") logger.info(f"Baseline F1: {suite.extraction_result.baseline_f1:.3f}") except Exception as e: logger.error(f"Extraction benchmark failed: {e}") # Run QA accuracy evaluation if answer function provided if answer_fn: logger.info("=" * 50) logger.info("Running QA Accuracy Preservation Evaluation") logger.info("=" * 50) try: suite.qa_result = evaluate_qa_accuracy_preservation( answer_fn=answer_fn, extractor=extractor, max_questions=qa_questions, ) logger.info(f"QA Accuracy (original): {suite.qa_result.accuracy_original_html:.3f}") logger.info(f"QA Accuracy (extracted): {suite.qa_result.accuracy_extracted:.3f}") logger.info(f"Accuracy preserved: {suite.qa_result.accuracy_preserved}") except Exception as e: logger.error(f"QA benchmark failed: {e}") return suite