"""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"""