mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
Add HTMLExtractor for web content extraction with OSS benchmarks
HTMLExtractor uses trafilatura to extract main content from HTML pages, removing scripts, styles, navigation, and ads. This achieves 94.9% compression while preserving 98.2% recall on the Scrapinghub benchmark. Key features: - Automatic HTML detection in content router - Configurable output format (markdown or text) - Metadata extraction (title, author, date, description) - Batch extraction support Evaluation framework: - OSS benchmark integration (Scrapinghub Article Extraction Benchmark) - LLM-as-judge evaluation for QA accuracy preservation - F1 score: 0.919 on 181-sample benchmark (baseline: 0.958) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
5c740ea427
commit
d1a28322cc
11 changed files with 6513 additions and 3123 deletions
644
headroom/evals/html_extraction.py
Normal file
644
headroom/evals/html_extraction.py
Normal file
|
|
@ -0,0 +1,644 @@
|
|||
"""Evaluation framework for HTML content extraction.
|
||||
|
||||
This module evaluates whether HTMLExtractor preserves the information
|
||||
that LLMs need to answer questions about web content. We compare:
|
||||
1. LLM answers from original HTML
|
||||
2. LLM answers from HTMLExtractor output
|
||||
3. LLM answers from LLMLingua baseline (current fallback)
|
||||
|
||||
Uses LLM-as-judge to score answer quality on a 1-5 scale.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from headroom.transforms.html_extractor import HTMLExtractor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# HTML Extraction Judge Prompt - optimized for content extraction evaluation
|
||||
HTML_JUDGE_PROMPT = """You are evaluating an HTML content extraction system.
|
||||
|
||||
The system extracts main content from web pages, removing scripts, styles,
|
||||
navigation, ads, and other noise while preserving the actual article content.
|
||||
|
||||
Given a question about a web page, the ground truth answer (from original HTML),
|
||||
and the system's answer (from extracted content), score the extraction quality:
|
||||
|
||||
5 = Perfect: The extracted content answer is semantically equivalent
|
||||
4 = Mostly correct: Minor details missing but main information preserved
|
||||
3 = Partially correct: Some key information present, some missing
|
||||
2 = Mostly incorrect: Significant information loss
|
||||
1 = Completely wrong: Critical content was removed during extraction
|
||||
|
||||
Question: {question}
|
||||
|
||||
Answer from Original HTML: {ground_truth}
|
||||
|
||||
Answer from Extracted Content: {prediction}
|
||||
|
||||
Consider:
|
||||
- Is the factual information preserved?
|
||||
- Are key details (names, dates, numbers) maintained?
|
||||
- Is the answer still complete and useful?
|
||||
|
||||
Format your response EXACTLY as:
|
||||
Reasoning: <your reasoning about information preservation>
|
||||
Score: <number 1-5>"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class HTMLEvalCase:
|
||||
"""A single HTML extraction evaluation case."""
|
||||
|
||||
id: str
|
||||
html: str # Original HTML content
|
||||
url: str | None # Source URL for context
|
||||
question: str # Question about the content
|
||||
ground_truth: str # Expected answer from the original
|
||||
category: str = "general" # news, docs, blog, product, etc.
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HTMLEvalResult:
|
||||
"""Result of a single HTML extraction evaluation."""
|
||||
|
||||
case_id: str
|
||||
category: str
|
||||
|
||||
# Content sizes
|
||||
original_html_length: int
|
||||
extracted_length: int
|
||||
compression_ratio: float
|
||||
|
||||
# Answers from different methods
|
||||
answer_from_original: str
|
||||
answer_from_extracted: str
|
||||
answer_from_baseline: str | None = None # LLMLingua baseline
|
||||
|
||||
# Judge scores (1-5 scale)
|
||||
extracted_score: float = 0.0
|
||||
extracted_reasoning: str = ""
|
||||
baseline_score: float | None = None
|
||||
baseline_reasoning: str | None = None
|
||||
|
||||
# Derived metrics
|
||||
@property
|
||||
def information_preserved(self) -> bool:
|
||||
"""True if extraction score >= 4 (mostly correct or better)."""
|
||||
return self.extracted_score >= 4.0
|
||||
|
||||
@property
|
||||
def extraction_wins(self) -> bool | None:
|
||||
"""True if extraction beats baseline, None if no baseline."""
|
||||
if self.baseline_score is None:
|
||||
return None
|
||||
return self.extracted_score > self.baseline_score
|
||||
|
||||
|
||||
@dataclass
|
||||
class HTMLEvalSuiteResult:
|
||||
"""Aggregated results from HTML extraction evaluation suite."""
|
||||
|
||||
total_cases: int
|
||||
results: list[HTMLEvalResult]
|
||||
|
||||
@property
|
||||
def avg_extraction_score(self) -> float:
|
||||
"""Average score for HTMLExtractor (1-5 scale)."""
|
||||
if not self.results:
|
||||
return 0.0
|
||||
return sum(r.extracted_score for r in self.results) / len(self.results)
|
||||
|
||||
@property
|
||||
def avg_baseline_score(self) -> float | None:
|
||||
"""Average score for baseline, None if no baseline tested."""
|
||||
baseline_results = [r for r in self.results if r.baseline_score is not None]
|
||||
if not baseline_results:
|
||||
return None
|
||||
return sum(
|
||||
r.baseline_score for r in baseline_results if r.baseline_score is not None
|
||||
) / len(baseline_results)
|
||||
|
||||
@property
|
||||
def information_preservation_rate(self) -> float:
|
||||
"""Percentage of cases where extraction score >= 4."""
|
||||
if not self.results:
|
||||
return 0.0
|
||||
preserved = sum(1 for r in self.results if r.information_preserved)
|
||||
return preserved / len(self.results) * 100
|
||||
|
||||
@property
|
||||
def extraction_win_rate(self) -> float | None:
|
||||
"""Percentage of cases where extraction beats baseline."""
|
||||
comparison_results = [r for r in self.results if r.baseline_score is not None]
|
||||
if not comparison_results:
|
||||
return None
|
||||
wins = sum(1 for r in comparison_results if r.extraction_wins)
|
||||
return wins / len(comparison_results) * 100
|
||||
|
||||
@property
|
||||
def avg_compression_ratio(self) -> float:
|
||||
"""Average compression ratio achieved."""
|
||||
if not self.results:
|
||||
return 0.0
|
||||
return sum(r.compression_ratio for r in self.results) / len(self.results)
|
||||
|
||||
def summary(self) -> dict[str, Any]:
|
||||
"""Return summary statistics."""
|
||||
return {
|
||||
"total_cases": self.total_cases,
|
||||
"avg_extraction_score": round(self.avg_extraction_score, 2),
|
||||
"avg_baseline_score": (
|
||||
round(self.avg_baseline_score, 2) if self.avg_baseline_score else None
|
||||
),
|
||||
"information_preservation_rate": round(self.information_preservation_rate, 1),
|
||||
"extraction_win_rate": (
|
||||
round(self.extraction_win_rate, 1) if self.extraction_win_rate else None
|
||||
),
|
||||
"avg_compression_ratio": round(self.avg_compression_ratio, 3),
|
||||
"by_category": self._results_by_category(),
|
||||
}
|
||||
|
||||
def _results_by_category(self) -> dict[str, dict[str, Any]]:
|
||||
"""Break down results by category."""
|
||||
categories: dict[str, list[HTMLEvalResult]] = {}
|
||||
for r in self.results:
|
||||
if r.category not in categories:
|
||||
categories[r.category] = []
|
||||
categories[r.category].append(r)
|
||||
|
||||
return {
|
||||
cat: {
|
||||
"count": len(results),
|
||||
"avg_score": round(sum(r.extracted_score for r in results) / len(results), 2),
|
||||
"preservation_rate": round(
|
||||
sum(1 for r in results if r.information_preserved) / len(results) * 100, 1
|
||||
),
|
||||
}
|
||||
for cat, results in categories.items()
|
||||
}
|
||||
|
||||
|
||||
class HTMLExtractionEvaluator:
|
||||
"""Evaluates HTML content extraction quality using LLM-as-judge.
|
||||
|
||||
Example:
|
||||
evaluator = HTMLExtractionEvaluator(
|
||||
answer_model="gpt-4o-mini",
|
||||
judge_model="gpt-4o",
|
||||
)
|
||||
results = evaluator.evaluate(eval_cases)
|
||||
print(f"Preservation rate: {results.information_preservation_rate}%")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
answer_model: str = "gpt-4o-mini",
|
||||
judge_model: str = "gpt-4o",
|
||||
compare_baseline: bool = True,
|
||||
provider: str = "openai",
|
||||
):
|
||||
"""Initialize the evaluator.
|
||||
|
||||
Args:
|
||||
answer_model: Model for generating answers from content.
|
||||
judge_model: Model for judging answer quality.
|
||||
compare_baseline: Whether to also test LLMLingua baseline.
|
||||
provider: API provider ("openai", "anthropic", "litellm").
|
||||
"""
|
||||
self.answer_model = answer_model
|
||||
self.judge_model = judge_model
|
||||
self.compare_baseline = compare_baseline
|
||||
self.provider = provider
|
||||
|
||||
# Lazy-loaded components
|
||||
self._extractor: HTMLExtractor | None = None
|
||||
self._llmlingua: Any = None
|
||||
self._judge_fn: Callable[[str, str, str], tuple[float, str]] | None = None
|
||||
self._answer_fn: Any = None
|
||||
|
||||
@property
|
||||
def extractor(self) -> HTMLExtractor:
|
||||
"""Lazy-load HTMLExtractor."""
|
||||
if self._extractor is None:
|
||||
from headroom.transforms.html_extractor import HTMLExtractor
|
||||
|
||||
self._extractor = HTMLExtractor()
|
||||
return self._extractor
|
||||
|
||||
@property
|
||||
def llmlingua(self) -> Any:
|
||||
"""Lazy-load LLMLingua compressor for baseline."""
|
||||
if self._llmlingua is None and self.compare_baseline:
|
||||
try:
|
||||
from headroom.transforms.llmlingua_compressor import LLMLinguaCompressor
|
||||
|
||||
self._llmlingua = LLMLinguaCompressor()
|
||||
except ImportError:
|
||||
logger.warning("LLMLingua not available for baseline comparison")
|
||||
return self._llmlingua
|
||||
|
||||
@property
|
||||
def judge_fn(self) -> Callable[[str, str, str], tuple[float, str]]:
|
||||
"""Lazy-load judge function."""
|
||||
if self._judge_fn is None:
|
||||
self._judge_fn = self._create_judge()
|
||||
assert self._judge_fn is not None # Always set by _create_judge or exception raised
|
||||
return self._judge_fn
|
||||
|
||||
def _create_judge(self) -> Callable[[str, str, str], tuple[float, str]]:
|
||||
"""Create the LLM judge function."""
|
||||
if self.provider == "openai":
|
||||
try:
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI()
|
||||
|
||||
def judge(question: str, ground_truth: str, prediction: str) -> tuple[float, str]:
|
||||
prompt = HTML_JUDGE_PROMPT.format(
|
||||
question=question,
|
||||
ground_truth=ground_truth,
|
||||
prediction=prediction,
|
||||
)
|
||||
response = client.chat.completions.create(
|
||||
model=self.judge_model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0.0,
|
||||
max_tokens=200,
|
||||
)
|
||||
return self._parse_judge_response(response.choices[0].message.content or "")
|
||||
|
||||
return judge
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"OpenAI package required. Install with: pip install openai"
|
||||
) from None
|
||||
|
||||
elif self.provider == "anthropic":
|
||||
try:
|
||||
import anthropic
|
||||
|
||||
client = anthropic.Anthropic()
|
||||
|
||||
def judge(question: str, ground_truth: str, prediction: str) -> tuple[float, str]:
|
||||
prompt = HTML_JUDGE_PROMPT.format(
|
||||
question=question,
|
||||
ground_truth=ground_truth,
|
||||
prediction=prediction,
|
||||
)
|
||||
response = client.messages.create(
|
||||
model=self.judge_model,
|
||||
max_tokens=200,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
text = response.content[0].text if response.content else ""
|
||||
return self._parse_judge_response(text)
|
||||
|
||||
return judge
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"Anthropic package required. Install with: pip install anthropic"
|
||||
) from None
|
||||
|
||||
else:
|
||||
try:
|
||||
import litellm
|
||||
|
||||
def judge(question: str, ground_truth: str, prediction: str) -> tuple[float, str]:
|
||||
prompt = HTML_JUDGE_PROMPT.format(
|
||||
question=question,
|
||||
ground_truth=ground_truth,
|
||||
prediction=prediction,
|
||||
)
|
||||
response = litellm.completion(
|
||||
model=self.judge_model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0.0,
|
||||
max_tokens=200,
|
||||
)
|
||||
return self._parse_judge_response(response.choices[0].message.content or "")
|
||||
|
||||
return judge
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"LiteLLM package required. Install with: pip install litellm"
|
||||
) from None
|
||||
|
||||
def _parse_judge_response(self, text: str) -> tuple[float, str]:
|
||||
"""Parse judge response to extract score and reasoning."""
|
||||
import re
|
||||
|
||||
reasoning = ""
|
||||
score = 3.0 # Default
|
||||
|
||||
for line in text.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if line.lower().startswith("reasoning:"):
|
||||
reasoning = line[len("reasoning:") :].strip()
|
||||
elif line.lower().startswith("score:"):
|
||||
match = re.search(r"(\d+(?:\.\d+)?)", line)
|
||||
if match:
|
||||
score = max(1.0, min(5.0, float(match.group(1))))
|
||||
|
||||
return score, reasoning or text.strip()
|
||||
|
||||
def _get_answer(self, content: str, question: str) -> str:
|
||||
"""Get LLM answer for a question given content."""
|
||||
prompt = f"""Based on the following content, answer the question.
|
||||
|
||||
Content:
|
||||
{content}
|
||||
|
||||
Question: {question}
|
||||
|
||||
Answer concisely and factually based only on the content provided."""
|
||||
|
||||
if self.provider == "openai":
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI()
|
||||
response = client.chat.completions.create(
|
||||
model=self.answer_model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0.0,
|
||||
max_tokens=500,
|
||||
)
|
||||
return response.choices[0].message.content or ""
|
||||
|
||||
elif self.provider == "anthropic":
|
||||
import anthropic
|
||||
|
||||
client = anthropic.Anthropic()
|
||||
response = client.messages.create(
|
||||
model=self.answer_model,
|
||||
max_tokens=500,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
return response.content[0].text if response.content else ""
|
||||
|
||||
else:
|
||||
import litellm
|
||||
|
||||
response = litellm.completion(
|
||||
model=self.answer_model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0.0,
|
||||
max_tokens=500,
|
||||
)
|
||||
return response.choices[0].message.content or ""
|
||||
|
||||
def evaluate_case(self, case: HTMLEvalCase) -> HTMLEvalResult:
|
||||
"""Evaluate a single HTML extraction case.
|
||||
|
||||
Args:
|
||||
case: The evaluation case with HTML, question, and ground truth.
|
||||
|
||||
Returns:
|
||||
HTMLEvalResult with scores and metrics.
|
||||
"""
|
||||
# Extract content
|
||||
extraction_result = self.extractor.extract(case.html, url=case.url)
|
||||
extracted_content = extraction_result.extracted
|
||||
|
||||
# Get answer from extracted content
|
||||
answer_from_extracted = self._get_answer(extracted_content, case.question)
|
||||
|
||||
# Get answer from original HTML (for comparison)
|
||||
answer_from_original = self._get_answer(case.html, case.question)
|
||||
|
||||
# Judge the extraction quality
|
||||
extracted_score, extracted_reasoning = self.judge_fn(
|
||||
case.question,
|
||||
case.ground_truth,
|
||||
answer_from_extracted,
|
||||
)
|
||||
|
||||
# Optionally compare with LLMLingua baseline
|
||||
baseline_answer = None
|
||||
baseline_score = None
|
||||
baseline_reasoning = None
|
||||
|
||||
if self.compare_baseline and self.llmlingua:
|
||||
try:
|
||||
baseline_result = self.llmlingua.compress(case.html)
|
||||
baseline_content = baseline_result.compressed
|
||||
baseline_answer = self._get_answer(baseline_content, case.question)
|
||||
baseline_score, baseline_reasoning = self.judge_fn(
|
||||
case.question,
|
||||
case.ground_truth,
|
||||
baseline_answer,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Baseline comparison failed: {e}")
|
||||
|
||||
return HTMLEvalResult(
|
||||
case_id=case.id,
|
||||
category=case.category,
|
||||
original_html_length=len(case.html),
|
||||
extracted_length=len(extracted_content),
|
||||
compression_ratio=extraction_result.compression_ratio,
|
||||
answer_from_original=answer_from_original,
|
||||
answer_from_extracted=answer_from_extracted,
|
||||
answer_from_baseline=baseline_answer,
|
||||
extracted_score=extracted_score,
|
||||
extracted_reasoning=extracted_reasoning,
|
||||
baseline_score=baseline_score,
|
||||
baseline_reasoning=baseline_reasoning,
|
||||
)
|
||||
|
||||
def evaluate(self, cases: list[HTMLEvalCase]) -> HTMLEvalSuiteResult:
|
||||
"""Evaluate a suite of HTML extraction cases.
|
||||
|
||||
Args:
|
||||
cases: List of evaluation cases.
|
||||
|
||||
Returns:
|
||||
HTMLEvalSuiteResult with aggregated metrics.
|
||||
"""
|
||||
results = []
|
||||
for i, case in enumerate(cases):
|
||||
logger.info(f"Evaluating case {i + 1}/{len(cases)}: {case.id}")
|
||||
try:
|
||||
result = self.evaluate_case(case)
|
||||
results.append(result)
|
||||
logger.info(
|
||||
f" Score: {result.extracted_score}/5, "
|
||||
f"Compression: {(1 - result.compression_ratio) * 100:.1f}%"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f" Failed: {e}")
|
||||
|
||||
return HTMLEvalSuiteResult(total_cases=len(cases), results=results)
|
||||
|
||||
|
||||
# Pre-built evaluation cases for testing
|
||||
def get_sample_eval_cases() -> list[HTMLEvalCase]:
|
||||
"""Get sample evaluation cases for testing.
|
||||
|
||||
Returns real HTML structures that test various extraction scenarios.
|
||||
"""
|
||||
return [
|
||||
HTMLEvalCase(
|
||||
id="news_article_1",
|
||||
category="news",
|
||||
url="https://example.com/news/tech-announcement",
|
||||
html="""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Tech Company Announces New AI Product</title>
|
||||
<script>var analytics = {track: function(){}};</script>
|
||||
<style>body { font-family: Arial; } .ad { display: block; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav><a href="/">Home</a> | <a href="/news">News</a> | <a href="/tech">Tech</a></nav>
|
||||
</header>
|
||||
<div class="ad-banner">Advertisement: Buy our product!</div>
|
||||
<article>
|
||||
<h1>Tech Company Announces Revolutionary AI Product</h1>
|
||||
<p class="byline">By Sarah Johnson | January 15, 2024</p>
|
||||
<p>TechCorp announced today the launch of their new AI assistant called "Aria"
|
||||
which will be available starting March 2024. The product is priced at $29.99
|
||||
per month for individual users.</p>
|
||||
<p>CEO John Smith stated: "Aria represents a breakthrough in conversational AI.
|
||||
We've trained it on over 100 billion parameters and it achieves 95% accuracy
|
||||
on standard benchmarks."</p>
|
||||
<p>The company expects to reach 10 million users within the first year.</p>
|
||||
</article>
|
||||
<aside>
|
||||
<h3>Related Articles</h3>
|
||||
<ul><li><a href="/article1">Other Tech News</a></li></ul>
|
||||
</aside>
|
||||
<footer><p>© 2024 News Site. Privacy Policy | Terms</p></footer>
|
||||
<script>analytics.track('pageview');</script>
|
||||
</body>
|
||||
</html>""",
|
||||
question="What is the name of the new AI product and when will it be available?",
|
||||
ground_truth="The new AI product is called 'Aria' and will be available starting March 2024.",
|
||||
),
|
||||
HTMLEvalCase(
|
||||
id="documentation_1",
|
||||
category="docs",
|
||||
url="https://docs.example.com/api/authentication",
|
||||
html="""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>API Documentation - Authentication</title>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="docs-sidebar">
|
||||
<a href="/docs">Home</a>
|
||||
<a href="/docs/quickstart">Quickstart</a>
|
||||
<a href="/docs/api">API Reference</a>
|
||||
</nav>
|
||||
<main class="docs-content">
|
||||
<h1>Authentication</h1>
|
||||
<p>All API requests require authentication using an API key.</p>
|
||||
<h2>Getting Your API Key</h2>
|
||||
<p>Sign up at dashboard.example.com to get your API key.
|
||||
Free tier includes 1000 requests per day.</p>
|
||||
<h2>Using the API Key</h2>
|
||||
<p>Include your API key in the Authorization header:</p>
|
||||
<pre><code>Authorization: Bearer YOUR_API_KEY</code></pre>
|
||||
<h2>Rate Limits</h2>
|
||||
<p>Free tier: 1000 requests/day. Pro tier: 100,000 requests/day.
|
||||
Enterprise: Unlimited.</p>
|
||||
</main>
|
||||
<footer>Built with DocsGen v3.0</footer>
|
||||
</body>
|
||||
</html>""",
|
||||
question="How many requests per day are included in the free tier?",
|
||||
ground_truth="The free tier includes 1000 requests per day.",
|
||||
),
|
||||
HTMLEvalCase(
|
||||
id="blog_post_1",
|
||||
category="blog",
|
||||
url="https://blog.example.com/lessons-learned",
|
||||
html="""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>5 Lessons I Learned Building My Startup - Personal Blog</title>
|
||||
<script src="analytics.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1 class="site-title">John's Tech Blog</h1>
|
||||
<nav>Home | About | Contact</nav>
|
||||
</header>
|
||||
<article class="blog-post">
|
||||
<h1>5 Lessons I Learned Building My Startup</h1>
|
||||
<p class="meta">Posted on December 10, 2023 by John Doe</p>
|
||||
<p>After 3 years of building StartupXYZ, here are my key takeaways:</p>
|
||||
<h2>1. Start with a small team</h2>
|
||||
<p>We started with just 3 co-founders and stayed lean for the first 18 months.</p>
|
||||
<h2>2. Focus on one thing</h2>
|
||||
<p>We tried 5 different products before finding product-market fit with our
|
||||
current offering - a B2B analytics platform.</p>
|
||||
<h2>3. Customer feedback is gold</h2>
|
||||
<p>We talked to over 200 potential customers before writing a single line of code.</p>
|
||||
</article>
|
||||
<section class="comments">
|
||||
<h3>Comments (47)</h3>
|
||||
<div class="comment">Great post!</div>
|
||||
</section>
|
||||
<footer>© 2023 John's Blog</footer>
|
||||
</body>
|
||||
</html>""",
|
||||
question="How many potential customers did they talk to before building the product?",
|
||||
ground_truth="They talked to over 200 potential customers before writing a single line of code.",
|
||||
),
|
||||
HTMLEvalCase(
|
||||
id="product_page_1",
|
||||
category="product",
|
||||
url="https://store.example.com/laptop-pro",
|
||||
html="""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Laptop Pro X1 - TechStore</title>
|
||||
<script>trackConversion();</script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav>Shop | Cart | Account</nav>
|
||||
<div class="search-bar"><input placeholder="Search..."></div>
|
||||
</header>
|
||||
<main class="product-page">
|
||||
<h1>Laptop Pro X1</h1>
|
||||
<div class="price">$1,299.99</div>
|
||||
<div class="specs">
|
||||
<h2>Specifications</h2>
|
||||
<ul>
|
||||
<li>Processor: Intel Core i7-12700H</li>
|
||||
<li>RAM: 16GB DDR5</li>
|
||||
<li>Storage: 512GB NVMe SSD</li>
|
||||
<li>Display: 14" 2K IPS, 120Hz</li>
|
||||
<li>Battery: Up to 12 hours</li>
|
||||
<li>Weight: 1.4 kg</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="description">
|
||||
<h2>Description</h2>
|
||||
<p>The Laptop Pro X1 is our flagship ultrabook, designed for professionals
|
||||
who need power and portability. Featuring the latest 12th gen Intel processor
|
||||
and a stunning 2K display.</p>
|
||||
</div>
|
||||
</main>
|
||||
<aside class="recommendations">
|
||||
<h3>You might also like</h3>
|
||||
<div class="product-card">Other Laptop</div>
|
||||
</aside>
|
||||
<footer>Free shipping on orders over $50</footer>
|
||||
</body>
|
||||
</html>""",
|
||||
question="What is the battery life and weight of the Laptop Pro X1?",
|
||||
ground_truth="The Laptop Pro X1 has up to 12 hours of battery life and weighs 1.4 kg.",
|
||||
),
|
||||
]
|
||||
495
headroom/evals/html_oss_benchmarks.py
Normal file
495
headroom/evals/html_oss_benchmarks.py
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
"""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"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Document</title></head>
|
||||
<body>
|
||||
<nav>Navigation Menu | Home | About</nav>
|
||||
<article>
|
||||
<h1>Content</h1>
|
||||
{context}
|
||||
</article>
|
||||
<footer>Copyright 2024</footer>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
# 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
|
||||
|
|
@ -41,6 +41,19 @@ try:
|
|||
except ImportError:
|
||||
_LLMLINGUA_AVAILABLE = False
|
||||
|
||||
# HTML content extraction (optional dependency - requires trafilatura)
|
||||
try:
|
||||
from .html_extractor import ( # noqa: F401
|
||||
HTMLExtractionResult,
|
||||
HTMLExtractor,
|
||||
HTMLExtractorConfig,
|
||||
is_html_content,
|
||||
)
|
||||
|
||||
_HTML_EXTRACTOR_AVAILABLE = True
|
||||
except ImportError:
|
||||
_HTML_EXTRACTOR_AVAILABLE = False
|
||||
|
||||
# AST-based code compression (optional dependency)
|
||||
from .code_compressor import (
|
||||
CodeAwareCompressor,
|
||||
|
|
@ -115,6 +128,8 @@ __all__ = [
|
|||
"EmbeddingProvider",
|
||||
# ML-based compression (optional)
|
||||
"_LLMLINGUA_AVAILABLE",
|
||||
# HTML extraction (optional)
|
||||
"_HTML_EXTRACTOR_AVAILABLE",
|
||||
]
|
||||
|
||||
# Conditionally add LLMLingua exports
|
||||
|
|
@ -129,3 +144,14 @@ if _LLMLINGUA_AVAILABLE:
|
|||
"unload_llmlingua_model",
|
||||
]
|
||||
)
|
||||
|
||||
# Conditionally add HTML extractor exports
|
||||
if _HTML_EXTRACTOR_AVAILABLE:
|
||||
__all__.extend(
|
||||
[
|
||||
"HTMLExtractor",
|
||||
"HTMLExtractorConfig",
|
||||
"HTMLExtractionResult",
|
||||
"is_html_content",
|
||||
]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ class ContentType(Enum):
|
|||
SEARCH_RESULTS = "search" # grep/ripgrep output
|
||||
BUILD_OUTPUT = "build" # Compiler, test, lint logs
|
||||
GIT_DIFF = "diff" # Unified diff format
|
||||
HTML = "html" # Web pages (needs content extraction, not compression)
|
||||
PLAIN_TEXT = "text" # Fallback
|
||||
|
||||
|
||||
|
|
@ -128,22 +129,27 @@ def detect_content_type(content: str) -> DetectionResult:
|
|||
if diff_result and diff_result.confidence >= 0.7:
|
||||
return diff_result
|
||||
|
||||
# 3. Check for search results (file:line: format)
|
||||
# 3. Check for HTML (very distinctive, needs extraction not compression)
|
||||
html_result = _try_detect_html(content)
|
||||
if html_result and html_result.confidence >= 0.7:
|
||||
return html_result
|
||||
|
||||
# 4. Check for search results (file:line: format)
|
||||
search_result = _try_detect_search(content)
|
||||
if search_result and search_result.confidence >= 0.6:
|
||||
return search_result
|
||||
|
||||
# 4. Check for build/log output
|
||||
# 5. Check for build/log output
|
||||
log_result = _try_detect_log(content)
|
||||
if log_result and log_result.confidence >= 0.5:
|
||||
return log_result
|
||||
|
||||
# 5. Check for source code
|
||||
# 6. Check for source code
|
||||
code_result = _try_detect_code(content)
|
||||
if code_result and code_result.confidence >= 0.5:
|
||||
return code_result
|
||||
|
||||
# 6. Fallback to plain text
|
||||
# 7. Fallback to plain text
|
||||
return DetectionResult(ContentType.PLAIN_TEXT, 0.5, {})
|
||||
|
||||
|
||||
|
|
@ -203,6 +209,75 @@ def _try_detect_diff(content: str) -> DetectionResult | None:
|
|||
)
|
||||
|
||||
|
||||
# HTML detection patterns
|
||||
_HTML_DOCTYPE_PATTERN = re.compile(r"^\s*<!doctype\s+html", re.IGNORECASE)
|
||||
_HTML_TAG_PATTERN = re.compile(r"<html[\s>]", re.IGNORECASE)
|
||||
_HTML_HEAD_PATTERN = re.compile(r"<head[\s>]", re.IGNORECASE)
|
||||
_HTML_BODY_PATTERN = re.compile(r"<body[\s>]", re.IGNORECASE)
|
||||
_HTML_STRUCTURAL_TAGS = re.compile(
|
||||
r"<(div|span|script|style|link|meta|nav|header|footer|aside|article|section|main)[\s>]",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _try_detect_html(content: str) -> DetectionResult | None:
|
||||
"""Try to detect HTML content.
|
||||
|
||||
HTML needs content extraction (removing scripts, styles, nav, etc.),
|
||||
not token-level compression like LLMLingua.
|
||||
"""
|
||||
# Check first 3000 chars for HTML indicators
|
||||
sample = content[:3000]
|
||||
|
||||
# Check for DOCTYPE (very strong signal)
|
||||
has_doctype = bool(_HTML_DOCTYPE_PATTERN.search(sample))
|
||||
|
||||
# Check for <html> tag
|
||||
has_html_tag = bool(_HTML_TAG_PATTERN.search(sample))
|
||||
|
||||
# Check for <head> or <body>
|
||||
has_head = bool(_HTML_HEAD_PATTERN.search(sample))
|
||||
has_body = bool(_HTML_BODY_PATTERN.search(sample))
|
||||
|
||||
# Count structural HTML tags
|
||||
structural_matches = len(_HTML_STRUCTURAL_TAGS.findall(sample))
|
||||
|
||||
# Quick rejection: not HTML if no indicators
|
||||
if not has_doctype and not has_html_tag and structural_matches < 3:
|
||||
return None
|
||||
|
||||
# Calculate confidence
|
||||
confidence = 0.0
|
||||
|
||||
if has_doctype:
|
||||
confidence += 0.5
|
||||
if has_html_tag:
|
||||
confidence += 0.3
|
||||
if has_head:
|
||||
confidence += 0.1
|
||||
if has_body:
|
||||
confidence += 0.1
|
||||
|
||||
# Structural tags contribute to confidence
|
||||
confidence += min(0.3, structural_matches * 0.03)
|
||||
|
||||
# Cap at 1.0
|
||||
confidence = min(1.0, confidence)
|
||||
|
||||
if confidence < 0.5:
|
||||
return None
|
||||
|
||||
return DetectionResult(
|
||||
ContentType.HTML,
|
||||
confidence,
|
||||
{
|
||||
"has_doctype": has_doctype,
|
||||
"has_html_tag": has_html_tag,
|
||||
"structural_tags": structural_matches,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _try_detect_search(content: str) -> DetectionResult | None:
|
||||
"""Try to detect grep/ripgrep search results."""
|
||||
lines = content.split("\n")[:100] # Check first 100 lines
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ class CompressionStrategy(Enum):
|
|||
LLMLINGUA = "llmlingua"
|
||||
TEXT = "text"
|
||||
DIFF = "diff"
|
||||
HTML = "html"
|
||||
MIXED = "mixed"
|
||||
PASSTHROUGH = "passthrough"
|
||||
|
||||
|
|
@ -232,6 +233,7 @@ class ContentRouterConfig:
|
|||
enable_smart_crusher: bool = True
|
||||
enable_search_compressor: bool = True
|
||||
enable_log_compressor: bool = True
|
||||
enable_html_extractor: bool = True # HTML content extraction
|
||||
enable_image_optimizer: bool = True # Image token optimization
|
||||
|
||||
# Routing preferences
|
||||
|
|
@ -462,6 +464,7 @@ class ContentRouter(Transform):
|
|||
self._search_compressor: Any = None
|
||||
self._log_compressor: Any = None
|
||||
self._diff_compressor: Any = None
|
||||
self._html_extractor: Any = None
|
||||
self._llmlingua: Any = None
|
||||
self._text_compressor: Any = None
|
||||
self._image_optimizer: Any = None
|
||||
|
|
@ -603,6 +606,7 @@ class ContentRouter(Transform):
|
|||
ContentType.SEARCH_RESULTS: CompressionStrategy.SEARCH,
|
||||
ContentType.BUILD_OUTPUT: CompressionStrategy.LOG,
|
||||
ContentType.GIT_DIFF: CompressionStrategy.DIFF,
|
||||
ContentType.HTML: CompressionStrategy.HTML,
|
||||
ContentType.PLAIN_TEXT: CompressionStrategy.TEXT,
|
||||
}
|
||||
|
||||
|
|
@ -782,6 +786,15 @@ class ContentRouter(Transform):
|
|||
result.compressed_line_count,
|
||||
)
|
||||
|
||||
elif strategy == CompressionStrategy.HTML:
|
||||
if self.config.enable_html_extractor:
|
||||
extractor = self._get_html_extractor()
|
||||
if extractor:
|
||||
result = extractor.extract(content)
|
||||
compressed = result.extracted
|
||||
# Estimate tokens from extracted text (simple word count)
|
||||
compressed_tokens = len(compressed.split()) if compressed else 0
|
||||
|
||||
elif strategy == CompressionStrategy.LLMLINGUA:
|
||||
compressed, compressed_tokens = self._try_llmlingua(content, context)
|
||||
|
||||
|
|
@ -844,6 +857,7 @@ class ContentRouter(Transform):
|
|||
ContentType.SEARCH_RESULTS: CompressionStrategy.SEARCH,
|
||||
ContentType.BUILD_OUTPUT: CompressionStrategy.LOG,
|
||||
ContentType.GIT_DIFF: CompressionStrategy.DIFF,
|
||||
ContentType.HTML: CompressionStrategy.HTML,
|
||||
ContentType.PLAIN_TEXT: CompressionStrategy.TEXT,
|
||||
}
|
||||
return mapping.get(content_type, self.config.fallback_strategy)
|
||||
|
|
@ -856,6 +870,7 @@ class ContentRouter(Transform):
|
|||
CompressionStrategy.SEARCH: ContentType.SEARCH_RESULTS,
|
||||
CompressionStrategy.LOG: ContentType.BUILD_OUTPUT,
|
||||
CompressionStrategy.DIFF: ContentType.GIT_DIFF,
|
||||
CompressionStrategy.HTML: ContentType.HTML,
|
||||
CompressionStrategy.TEXT: ContentType.PLAIN_TEXT,
|
||||
CompressionStrategy.LLMLINGUA: ContentType.PLAIN_TEXT,
|
||||
CompressionStrategy.PASSTHROUGH: ContentType.PLAIN_TEXT,
|
||||
|
|
@ -928,6 +943,17 @@ class ContentRouter(Transform):
|
|||
logger.debug("DiffCompressor not available")
|
||||
return self._diff_compressor
|
||||
|
||||
def _get_html_extractor(self) -> Any:
|
||||
"""Get HTMLExtractor (lazy load)."""
|
||||
if self._html_extractor is None:
|
||||
try:
|
||||
from .html_extractor import HTMLExtractor
|
||||
|
||||
self._html_extractor = HTMLExtractor()
|
||||
except ImportError:
|
||||
logger.debug("HTMLExtractor not available (install trafilatura)")
|
||||
return self._html_extractor
|
||||
|
||||
def _get_llmlingua(self) -> Any:
|
||||
"""Get LLMLinguaCompressor (lazy load)."""
|
||||
if self._llmlingua is None:
|
||||
|
|
|
|||
228
headroom/transforms/html_extractor.py
Normal file
228
headroom/transforms/html_extractor.py
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
"""HTML content extractor for web scraping results.
|
||||
|
||||
This module extracts main content from HTML pages, removing structural noise
|
||||
like scripts, styles, navigation, ads, and footers. This is content extraction,
|
||||
not compression - we remove irrelevant blocks, not tokens.
|
||||
|
||||
Typical reduction: 70-90% with zero content loss.
|
||||
|
||||
Uses trafilatura for robust extraction - it handles:
|
||||
- Article/main content detection
|
||||
- Boilerplate removal (nav, footer, sidebar, ads)
|
||||
- Script/style removal
|
||||
- Metadata extraction (title, author, date)
|
||||
- Output as clean text or markdown
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import trafilatura
|
||||
from trafilatura.settings import use_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HTMLExtractionResult:
|
||||
"""Result of HTML content extraction."""
|
||||
|
||||
extracted: str
|
||||
original: str
|
||||
original_length: int
|
||||
extracted_length: int
|
||||
compression_ratio: float
|
||||
title: str | None = None
|
||||
author: str | None = None
|
||||
date: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def reduction_percent(self) -> float:
|
||||
"""Percentage of content removed."""
|
||||
if self.original_length == 0:
|
||||
return 0.0
|
||||
return (1 - self.compression_ratio) * 100
|
||||
|
||||
|
||||
@dataclass
|
||||
class HTMLExtractorConfig:
|
||||
"""Configuration for HTML extraction."""
|
||||
|
||||
# Output format
|
||||
output_format: str = "markdown" # "markdown" or "text"
|
||||
include_links: bool = True
|
||||
include_images: bool = False
|
||||
include_tables: bool = True
|
||||
|
||||
# Extraction behavior
|
||||
include_comments: bool = False
|
||||
include_formatting: bool = True
|
||||
favor_precision: bool = False # True = less content but higher quality
|
||||
favor_recall: bool = True # True = more content, may include some noise
|
||||
|
||||
# Metadata extraction
|
||||
extract_metadata: bool = True
|
||||
|
||||
|
||||
class HTMLExtractor:
|
||||
"""Extracts main content from HTML pages.
|
||||
|
||||
Uses trafilatura for robust content extraction. This is not compression -
|
||||
it's removing structural HTML noise (scripts, styles, nav, ads) to get
|
||||
the actual content the user wanted.
|
||||
|
||||
Example:
|
||||
>>> extractor = HTMLExtractor()
|
||||
>>> result = extractor.extract(html_content)
|
||||
>>> print(result.extracted) # Clean markdown/text
|
||||
>>> print(f"Reduced by {result.reduction_percent:.1f}%")
|
||||
"""
|
||||
|
||||
def __init__(self, config: HTMLExtractorConfig | None = None):
|
||||
"""Initialize HTML extractor.
|
||||
|
||||
Args:
|
||||
config: Extraction configuration.
|
||||
"""
|
||||
self.config = config or HTMLExtractorConfig()
|
||||
self._trafilatura_config = self._build_trafilatura_config()
|
||||
|
||||
def _build_trafilatura_config(self) -> Any:
|
||||
"""Build trafilatura configuration from our config."""
|
||||
config = use_config()
|
||||
|
||||
# Set extraction parameters
|
||||
config.set("DEFAULT", "FAVOR_PRECISION", str(self.config.favor_precision))
|
||||
config.set("DEFAULT", "FAVOR_RECALL", str(self.config.favor_recall))
|
||||
|
||||
return config
|
||||
|
||||
def extract(self, html: str, url: str | None = None) -> HTMLExtractionResult:
|
||||
"""Extract main content from HTML.
|
||||
|
||||
Args:
|
||||
html: Raw HTML content.
|
||||
url: Optional URL for better extraction (helps with relative links).
|
||||
|
||||
Returns:
|
||||
HTMLExtractionResult with extracted content and metadata.
|
||||
"""
|
||||
original_length = len(html)
|
||||
|
||||
if not html or not html.strip():
|
||||
return HTMLExtractionResult(
|
||||
extracted="",
|
||||
original=html,
|
||||
original_length=original_length,
|
||||
extracted_length=0,
|
||||
compression_ratio=0.0,
|
||||
)
|
||||
|
||||
# Extract content using trafilatura
|
||||
extracted = trafilatura.extract(
|
||||
html,
|
||||
url=url,
|
||||
include_links=self.config.include_links,
|
||||
include_images=self.config.include_images,
|
||||
include_tables=self.config.include_tables,
|
||||
include_comments=self.config.include_comments,
|
||||
include_formatting=self.config.include_formatting,
|
||||
output_format=self.config.output_format,
|
||||
config=self._trafilatura_config,
|
||||
)
|
||||
|
||||
# Handle extraction failure
|
||||
if extracted is None:
|
||||
logger.debug("trafilatura extraction returned None, returning empty")
|
||||
extracted = ""
|
||||
|
||||
extracted_length = len(extracted)
|
||||
compression_ratio = extracted_length / max(original_length, 1)
|
||||
|
||||
# Extract metadata if configured
|
||||
title = None
|
||||
author = None
|
||||
date = None
|
||||
metadata: dict[str, Any] = {}
|
||||
|
||||
if self.config.extract_metadata:
|
||||
meta = trafilatura.extract_metadata(html, default_url=url)
|
||||
if meta:
|
||||
title = meta.title
|
||||
author = meta.author
|
||||
date = meta.date
|
||||
metadata = {
|
||||
"title": meta.title,
|
||||
"author": meta.author,
|
||||
"date": meta.date,
|
||||
"sitename": meta.sitename,
|
||||
"description": meta.description,
|
||||
"categories": meta.categories,
|
||||
"tags": meta.tags,
|
||||
}
|
||||
|
||||
return HTMLExtractionResult(
|
||||
extracted=extracted,
|
||||
original=html,
|
||||
original_length=original_length,
|
||||
extracted_length=extracted_length,
|
||||
compression_ratio=compression_ratio,
|
||||
title=title,
|
||||
author=author,
|
||||
date=date,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
def extract_batch(
|
||||
self, html_contents: list[tuple[str, str | None]]
|
||||
) -> list[HTMLExtractionResult]:
|
||||
"""Extract content from multiple HTML pages.
|
||||
|
||||
Args:
|
||||
html_contents: List of (html, url) tuples.
|
||||
|
||||
Returns:
|
||||
List of HTMLExtractionResult in same order as input.
|
||||
"""
|
||||
return [self.extract(html, url) for html, url in html_contents]
|
||||
|
||||
|
||||
def is_html_content(content: str) -> bool:
|
||||
"""Check if content appears to be HTML.
|
||||
|
||||
Args:
|
||||
content: Content to check.
|
||||
|
||||
Returns:
|
||||
True if content looks like HTML.
|
||||
"""
|
||||
if not content:
|
||||
return False
|
||||
|
||||
stripped = content.strip().lower()
|
||||
|
||||
# Check for DOCTYPE or html tag
|
||||
if stripped.startswith("<!doctype html") or stripped.startswith("<html"):
|
||||
return True
|
||||
|
||||
# Check for common HTML patterns
|
||||
html_indicators = [
|
||||
"<head",
|
||||
"<body",
|
||||
"<div",
|
||||
"<script",
|
||||
"<style",
|
||||
"<meta",
|
||||
"<link",
|
||||
"<!doctype",
|
||||
]
|
||||
|
||||
# Count how many indicators are present
|
||||
matches = sum(1 for indicator in html_indicators if indicator in stripped[:2000])
|
||||
|
||||
# If we see multiple HTML-specific tags, it's likely HTML
|
||||
return matches >= 2
|
||||
|
|
@ -123,6 +123,10 @@ memory = [
|
|||
bedrock = [
|
||||
"boto3>=1.28.0",
|
||||
]
|
||||
# HTML content extraction (web scraping results)
|
||||
html = [
|
||||
"trafilatura>=1.6.0",
|
||||
]
|
||||
# Development dependencies
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
|
|
@ -139,7 +143,7 @@ dev = [
|
|||
]
|
||||
# All optional dependencies
|
||||
all = [
|
||||
"headroom-ai[relevance,proxy,reports,llmlingua,code,evals,memory,voice]",
|
||||
"headroom-ai[relevance,proxy,reports,llmlingua,code,evals,memory,voice,html]",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
|
|
|||
308
tests/test_evals/test_html_extraction_eval.py
Normal file
308
tests/test_evals/test_html_extraction_eval.py
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
"""Tests for HTML extraction evaluation.
|
||||
|
||||
These tests verify that the HTML extraction preserves information
|
||||
that LLMs need to answer questions about web content.
|
||||
|
||||
Run with actual LLM calls:
|
||||
pytest tests/test_evals/test_html_extraction_eval.py -v -s
|
||||
|
||||
Skip LLM calls (just test infrastructure):
|
||||
pytest tests/test_evals/test_html_extraction_eval.py -v -k "not llm"
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from headroom.evals.html_extraction import (
|
||||
HTMLEvalCase,
|
||||
HTMLEvalResult,
|
||||
HTMLEvalSuiteResult,
|
||||
HTMLExtractionEvaluator,
|
||||
get_sample_eval_cases,
|
||||
)
|
||||
from headroom.transforms.html_extractor import HTMLExtractor
|
||||
|
||||
|
||||
class TestHTMLEvalInfrastructure:
|
||||
"""Tests for evaluation infrastructure (no LLM calls)."""
|
||||
|
||||
def test_sample_cases_available(self):
|
||||
"""Verify sample evaluation cases are available."""
|
||||
cases = get_sample_eval_cases()
|
||||
assert len(cases) >= 4
|
||||
assert all(isinstance(c, HTMLEvalCase) for c in cases)
|
||||
|
||||
def test_case_categories(self):
|
||||
"""Verify cases cover different categories."""
|
||||
cases = get_sample_eval_cases()
|
||||
categories = {c.category for c in cases}
|
||||
assert "news" in categories
|
||||
assert "docs" in categories
|
||||
assert "blog" in categories
|
||||
|
||||
def test_eval_result_properties(self):
|
||||
"""Test HTMLEvalResult computed properties."""
|
||||
result = HTMLEvalResult(
|
||||
case_id="test",
|
||||
category="news",
|
||||
original_html_length=1000,
|
||||
extracted_length=300,
|
||||
compression_ratio=0.3,
|
||||
answer_from_original="Answer A",
|
||||
answer_from_extracted="Answer B",
|
||||
extracted_score=4.5,
|
||||
extracted_reasoning="Good extraction",
|
||||
)
|
||||
|
||||
assert result.information_preserved is True # score >= 4
|
||||
assert result.extraction_wins is None # no baseline
|
||||
|
||||
def test_eval_result_with_baseline(self):
|
||||
"""Test HTMLEvalResult with baseline comparison."""
|
||||
result = HTMLEvalResult(
|
||||
case_id="test",
|
||||
category="news",
|
||||
original_html_length=1000,
|
||||
extracted_length=300,
|
||||
compression_ratio=0.3,
|
||||
answer_from_original="Answer A",
|
||||
answer_from_extracted="Answer B",
|
||||
answer_from_baseline="Answer C",
|
||||
extracted_score=4.5,
|
||||
extracted_reasoning="Good extraction",
|
||||
baseline_score=3.0,
|
||||
baseline_reasoning="Partial extraction",
|
||||
)
|
||||
|
||||
assert result.information_preserved is True
|
||||
assert result.extraction_wins is True # 4.5 > 3.0
|
||||
|
||||
def test_suite_result_aggregation(self):
|
||||
"""Test HTMLEvalSuiteResult aggregation."""
|
||||
results = [
|
||||
HTMLEvalResult(
|
||||
case_id="1",
|
||||
category="news",
|
||||
original_html_length=1000,
|
||||
extracted_length=300,
|
||||
compression_ratio=0.3,
|
||||
answer_from_original="A",
|
||||
answer_from_extracted="B",
|
||||
extracted_score=5.0,
|
||||
extracted_reasoning="Perfect",
|
||||
),
|
||||
HTMLEvalResult(
|
||||
case_id="2",
|
||||
category="docs",
|
||||
original_html_length=800,
|
||||
extracted_length=200,
|
||||
compression_ratio=0.25,
|
||||
answer_from_original="A",
|
||||
answer_from_extracted="B",
|
||||
extracted_score=4.0,
|
||||
extracted_reasoning="Good",
|
||||
),
|
||||
HTMLEvalResult(
|
||||
case_id="3",
|
||||
category="news",
|
||||
original_html_length=1200,
|
||||
extracted_length=400,
|
||||
compression_ratio=0.33,
|
||||
answer_from_original="A",
|
||||
answer_from_extracted="B",
|
||||
extracted_score=3.0,
|
||||
extracted_reasoning="Partial",
|
||||
),
|
||||
]
|
||||
|
||||
suite = HTMLEvalSuiteResult(total_cases=3, results=results)
|
||||
|
||||
assert suite.avg_extraction_score == 4.0 # (5+4+3)/3
|
||||
assert suite.information_preservation_rate == pytest.approx(66.67, rel=0.1) # 2/3
|
||||
assert suite.avg_compression_ratio == pytest.approx(0.293, rel=0.1)
|
||||
|
||||
summary = suite.summary()
|
||||
assert summary["total_cases"] == 3
|
||||
assert "by_category" in summary
|
||||
assert "news" in summary["by_category"]
|
||||
assert "docs" in summary["by_category"]
|
||||
|
||||
|
||||
class TestHTMLExtractionQuality:
|
||||
"""Tests that verify extraction quality without LLM calls."""
|
||||
|
||||
@pytest.fixture
|
||||
def extractor(self):
|
||||
return HTMLExtractor()
|
||||
|
||||
def test_extracts_article_content(self, extractor):
|
||||
"""Test that article content is extracted from sample cases."""
|
||||
cases = get_sample_eval_cases()
|
||||
|
||||
for case in cases:
|
||||
result = extractor.extract(case.html, url=case.url)
|
||||
|
||||
# Extraction should produce non-empty content
|
||||
assert len(result.extracted) > 0
|
||||
|
||||
# Should achieve significant compression
|
||||
assert result.compression_ratio < 0.7 # At least 30% reduction
|
||||
|
||||
def test_removes_noise(self, extractor):
|
||||
"""Test that scripts, styles, nav are removed."""
|
||||
cases = get_sample_eval_cases()
|
||||
|
||||
for case in cases:
|
||||
result = extractor.extract(case.html, url=case.url)
|
||||
extracted = result.extracted.lower()
|
||||
|
||||
# Should not contain JavaScript code patterns
|
||||
assert "trackconversion" not in extracted
|
||||
assert "var analytics" not in extracted
|
||||
assert "function()" not in extracted
|
||||
assert "console.log" not in extracted
|
||||
|
||||
# Should not contain CSS
|
||||
assert "font-family" not in extracted
|
||||
assert "display: block" not in extracted
|
||||
assert "font-family: arial" not in extracted
|
||||
|
||||
def test_preserves_key_information(self, extractor):
|
||||
"""Test that key facts from questions are preserved in extraction."""
|
||||
cases = get_sample_eval_cases()
|
||||
|
||||
# Check specific facts that should be preserved
|
||||
fact_checks = {
|
||||
"news_article_1": ["aria", "march 2024", "$29.99"],
|
||||
"documentation_1": ["1000", "api key", "authorization"],
|
||||
"blog_post_1": ["200", "customers", "3 years"],
|
||||
"product_page_1": ["$1,299.99", "12 hours", "1.4 kg"],
|
||||
}
|
||||
|
||||
for case in cases:
|
||||
if case.id in fact_checks:
|
||||
result = extractor.extract(case.html, url=case.url)
|
||||
extracted_lower = result.extracted.lower()
|
||||
|
||||
for fact in fact_checks[case.id]:
|
||||
assert fact.lower() in extracted_lower, (
|
||||
f"Fact '{fact}' missing from {case.id} extraction"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
|
||||
class TestHTMLExtractionWithLLM:
|
||||
"""Tests that use actual LLM calls for evaluation.
|
||||
|
||||
These tests verify that the extracted content allows LLMs to
|
||||
answer questions correctly.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def evaluator(self):
|
||||
"""Create evaluator with OpenAI."""
|
||||
return HTMLExtractionEvaluator(
|
||||
answer_model="gpt-4o-mini",
|
||||
judge_model="gpt-4o-mini", # Use mini for faster/cheaper tests
|
||||
compare_baseline=False, # Skip baseline for speed
|
||||
provider="openai",
|
||||
)
|
||||
|
||||
def test_single_case_evaluation(self, evaluator):
|
||||
"""Test evaluation of a single case."""
|
||||
case = get_sample_eval_cases()[0] # News article
|
||||
|
||||
result = evaluator.evaluate_case(case)
|
||||
|
||||
# Should get a valid score
|
||||
assert 1.0 <= result.extracted_score <= 5.0
|
||||
assert result.extracted_reasoning != ""
|
||||
|
||||
# Should achieve compression
|
||||
assert result.compression_ratio < 0.5
|
||||
|
||||
# Print for manual inspection
|
||||
print(f"\nCase: {result.case_id}")
|
||||
print(f"Score: {result.extracted_score}/5")
|
||||
print(f"Reasoning: {result.extracted_reasoning}")
|
||||
print(f"Compression: {(1 - result.compression_ratio) * 100:.1f}%")
|
||||
|
||||
def test_full_suite_evaluation(self, evaluator):
|
||||
"""Test evaluation of all sample cases."""
|
||||
cases = get_sample_eval_cases()
|
||||
|
||||
results = evaluator.evaluate(cases)
|
||||
|
||||
# Should evaluate all cases
|
||||
assert results.total_cases == len(cases)
|
||||
assert len(results.results) == len(cases)
|
||||
|
||||
# Print summary
|
||||
summary = results.summary()
|
||||
print(f"\n{'=' * 50}")
|
||||
print("HTML Extraction Evaluation Results")
|
||||
print(f"{'=' * 50}")
|
||||
print(f"Total cases: {summary['total_cases']}")
|
||||
print(f"Avg extraction score: {summary['avg_extraction_score']}/5")
|
||||
print(f"Information preservation rate: {summary['information_preservation_rate']}%")
|
||||
print(f"Avg compression ratio: {summary['avg_compression_ratio']:.1%}")
|
||||
print("\nBy category:")
|
||||
for cat, stats in summary["by_category"].items():
|
||||
print(f" {cat}: {stats['avg_score']}/5 ({stats['count']} cases)")
|
||||
|
||||
# Should preserve information in most cases
|
||||
assert results.information_preservation_rate >= 75.0, (
|
||||
f"Information preservation rate too low: {results.information_preservation_rate}%"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
|
||||
class TestHTMLvsBaseline:
|
||||
"""Tests comparing HTMLExtractor vs LLMLingua baseline."""
|
||||
|
||||
@pytest.fixture
|
||||
def evaluator_with_baseline(self):
|
||||
"""Create evaluator that compares against baseline."""
|
||||
return HTMLExtractionEvaluator(
|
||||
answer_model="gpt-4o-mini",
|
||||
judge_model="gpt-4o-mini",
|
||||
compare_baseline=True,
|
||||
provider="openai",
|
||||
)
|
||||
|
||||
@pytest.mark.skipif(True, reason="LLMLingua requires GPU, skip in CI")
|
||||
def test_extraction_beats_baseline(self, evaluator_with_baseline):
|
||||
"""Test that HTMLExtractor outperforms LLMLingua on HTML."""
|
||||
cases = get_sample_eval_cases()[:2] # Just test 2 for speed
|
||||
|
||||
results = evaluator_with_baseline.evaluate(cases)
|
||||
|
||||
if results.extraction_win_rate is not None:
|
||||
print(f"\nExtraction win rate: {results.extraction_win_rate}%")
|
||||
print(f"Avg extraction score: {results.avg_extraction_score}/5")
|
||||
print(f"Avg baseline score: {results.avg_baseline_score}/5")
|
||||
|
||||
# HTMLExtractor should beat LLMLingua on HTML content
|
||||
assert results.avg_extraction_score >= results.avg_baseline_score, (
|
||||
"HTMLExtractor should perform at least as well as LLMLingua on HTML"
|
||||
)
|
||||
|
||||
|
||||
class TestEvaluatorConfiguration:
|
||||
"""Tests for evaluator configuration."""
|
||||
|
||||
def test_lazy_loading(self):
|
||||
"""Test that components are lazy loaded."""
|
||||
evaluator = HTMLExtractionEvaluator()
|
||||
|
||||
# Components should not be loaded yet
|
||||
assert evaluator._extractor is None
|
||||
assert evaluator._judge_fn is None
|
||||
|
||||
def test_different_providers(self):
|
||||
"""Test that different providers can be configured."""
|
||||
# These should not fail (just create the evaluator)
|
||||
HTMLExtractionEvaluator(provider="openai")
|
||||
HTMLExtractionEvaluator(provider="anthropic")
|
||||
HTMLExtractionEvaluator(provider="litellm")
|
||||
381
tests/test_evals/test_html_oss_benchmarks.py
Normal file
381
tests/test_evals/test_html_oss_benchmarks.py
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
"""Tests using OSS benchmarks for HTML extraction evaluation.
|
||||
|
||||
These tests use established open-source benchmarks to verify that
|
||||
HTMLExtractor does not lose accuracy:
|
||||
|
||||
1. Scrapinghub Article Extraction Benchmark
|
||||
- Measures extraction quality (F1 score)
|
||||
- Baseline: trafilatura achieves 0.958 F1
|
||||
|
||||
2. SQuAD/HotpotQA for QA accuracy preservation
|
||||
- Measures whether extraction preserves answer accuracy
|
||||
|
||||
Run extraction benchmark only (no API calls):
|
||||
pytest tests/test_evals/test_html_oss_benchmarks.py -k "extraction" -v
|
||||
|
||||
Run full suite with LLM (requires OPENAI_API_KEY):
|
||||
pytest tests/test_evals/test_html_oss_benchmarks.py -v -s
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestExtractionBenchmark:
|
||||
"""Tests using Scrapinghub Article Extraction Benchmark.
|
||||
|
||||
This is the gold standard for article extraction evaluation.
|
||||
No LLM calls required - just measures F1 against ground truth.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def extractor(self):
|
||||
from headroom.transforms.html_extractor import HTMLExtractor
|
||||
|
||||
return HTMLExtractor()
|
||||
|
||||
def test_benchmark_loads(self):
|
||||
"""Verify we can load the benchmark dataset."""
|
||||
pytest.importorskip("datasets")
|
||||
from datasets import load_dataset
|
||||
|
||||
dataset = load_dataset("allenai/scrapinghub-article-extraction-benchmark")
|
||||
assert "train" in dataset
|
||||
assert len(dataset["train"]) > 0
|
||||
|
||||
# Check expected fields
|
||||
sample = dataset["train"][0]
|
||||
assert "html" in sample
|
||||
assert "articleBody" in sample
|
||||
|
||||
def test_extraction_f1_quick(self, extractor):
|
||||
"""Quick test: evaluate on 10 samples."""
|
||||
pytest.importorskip("datasets")
|
||||
from headroom.evals.html_oss_benchmarks import evaluate_scrapinghub_benchmark
|
||||
|
||||
result = evaluate_scrapinghub_benchmark(
|
||||
extractor=extractor,
|
||||
max_samples=10,
|
||||
)
|
||||
|
||||
# Should get reasonable F1 (> 0.8)
|
||||
assert result.avg_f1 > 0.8, f"F1 too low: {result.avg_f1}"
|
||||
assert result.avg_precision > 0.7
|
||||
assert result.avg_recall > 0.7
|
||||
|
||||
# Print results
|
||||
print("\nQuick Extraction Benchmark (10 samples):")
|
||||
print(f" Precision: {result.avg_precision:.3f}")
|
||||
print(f" Recall: {result.avg_recall:.3f}")
|
||||
print(f" F1: {result.avg_f1:.3f}")
|
||||
print(f" Baseline: {result.baseline_f1:.3f}")
|
||||
|
||||
def test_extraction_f1_medium(self, extractor):
|
||||
"""Medium test: evaluate on 50 samples."""
|
||||
pytest.importorskip("datasets")
|
||||
from headroom.evals.html_oss_benchmarks import evaluate_scrapinghub_benchmark
|
||||
|
||||
result = evaluate_scrapinghub_benchmark(
|
||||
extractor=extractor,
|
||||
max_samples=50,
|
||||
)
|
||||
|
||||
# Should approach baseline performance (0.958)
|
||||
# Allow some margin since our extractor may differ slightly
|
||||
assert result.avg_f1 > 0.85, f"F1 too low: {result.avg_f1}"
|
||||
|
||||
print("\nMedium Extraction Benchmark (50 samples):")
|
||||
print(f" Precision: {result.avg_precision:.3f}")
|
||||
print(f" Recall: {result.avg_recall:.3f}")
|
||||
print(f" F1: {result.avg_f1:.3f}")
|
||||
print(f" Baseline: {result.baseline_f1:.3f}")
|
||||
print(f" Matches baseline: {result.matches_baseline}")
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_extraction_f1_full(self, extractor):
|
||||
"""Full test: evaluate on all 181 samples."""
|
||||
pytest.importorskip("datasets")
|
||||
from headroom.evals.html_oss_benchmarks import evaluate_scrapinghub_benchmark
|
||||
|
||||
result = evaluate_scrapinghub_benchmark(
|
||||
extractor=extractor,
|
||||
max_samples=None, # All samples
|
||||
)
|
||||
|
||||
# Should match or exceed baseline
|
||||
assert result.avg_f1 > 0.90, f"F1 too low: {result.avg_f1}"
|
||||
|
||||
print(f"\nFull Extraction Benchmark ({result.total_samples} samples):")
|
||||
print(f" Precision: {result.avg_precision:.3f}")
|
||||
print(f" Recall: {result.avg_recall:.3f}")
|
||||
print(f" F1: {result.avg_f1:.3f}")
|
||||
print(f" Baseline: {result.baseline_f1:.3f}")
|
||||
print(f" Matches baseline: {result.matches_baseline}")
|
||||
print(f" Beats baseline: {result.beats_baseline}")
|
||||
|
||||
def test_compression_achieved(self, extractor):
|
||||
"""Verify we achieve meaningful compression."""
|
||||
pytest.importorskip("datasets")
|
||||
from headroom.evals.html_oss_benchmarks import evaluate_scrapinghub_benchmark
|
||||
|
||||
result = evaluate_scrapinghub_benchmark(
|
||||
extractor=extractor,
|
||||
max_samples=20,
|
||||
)
|
||||
|
||||
# Should achieve significant compression (ratio < 0.5 = 50%+ reduction)
|
||||
assert result.avg_compression_ratio < 0.5, (
|
||||
f"Compression ratio too high: {result.avg_compression_ratio}"
|
||||
)
|
||||
|
||||
print("\nCompression Results:")
|
||||
print(f" Avg compression ratio: {result.avg_compression_ratio:.3f}")
|
||||
print(f" Avg reduction: {(1 - result.avg_compression_ratio) * 100:.1f}%")
|
||||
|
||||
|
||||
class TestMetrics:
|
||||
"""Tests for evaluation metrics."""
|
||||
|
||||
def test_f1_computation(self):
|
||||
from headroom.evals.html_oss_benchmarks import compute_f1
|
||||
|
||||
# Perfect match
|
||||
p, r, f1 = compute_f1("hello world", "hello world")
|
||||
assert f1 == 1.0
|
||||
|
||||
# Partial match
|
||||
p, r, f1 = compute_f1("hello world foo", "hello world bar")
|
||||
assert 0.5 < f1 < 1.0
|
||||
|
||||
# No match
|
||||
p, r, f1 = compute_f1("foo bar", "hello world")
|
||||
assert f1 == 0.0
|
||||
|
||||
def test_exact_match(self):
|
||||
from headroom.evals.html_oss_benchmarks import compute_exact_match
|
||||
|
||||
assert compute_exact_match("hello world", "Hello World") is True
|
||||
assert compute_exact_match("hello", "hello world") is False
|
||||
|
||||
|
||||
@pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
|
||||
class TestQAAccuracyPreservation:
|
||||
"""Tests that verify QA accuracy is preserved after extraction.
|
||||
|
||||
These tests require an LLM to answer questions, then compare
|
||||
accuracy on original HTML vs extracted content.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def answer_fn(self):
|
||||
"""Create an answer function using OpenAI."""
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI()
|
||||
|
||||
def answer(context: str, question: str) -> str:
|
||||
prompt = f"""Based on the following content, answer the question concisely.
|
||||
|
||||
Content:
|
||||
{context[:4000]} # Limit context size
|
||||
|
||||
Question: {question}
|
||||
|
||||
Answer:"""
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0.0,
|
||||
max_tokens=100,
|
||||
)
|
||||
return response.choices[0].message.content or ""
|
||||
|
||||
return answer
|
||||
|
||||
def test_qa_accuracy_squad_quick(self, answer_fn):
|
||||
"""Quick QA accuracy test on 10 SQuAD questions."""
|
||||
pytest.importorskip("datasets")
|
||||
from headroom.evals.html_oss_benchmarks import evaluate_qa_accuracy_preservation
|
||||
|
||||
result = evaluate_qa_accuracy_preservation(
|
||||
answer_fn=answer_fn,
|
||||
max_questions=10,
|
||||
dataset_name="squad",
|
||||
)
|
||||
|
||||
# Accuracy should be preserved (within 5%)
|
||||
assert result.accuracy_preserved, (
|
||||
f"Accuracy not preserved: original={result.accuracy_original_html:.3f}, "
|
||||
f"extracted={result.accuracy_extracted:.3f}"
|
||||
)
|
||||
|
||||
print("\nQA Accuracy (10 questions):")
|
||||
print(f" Original HTML: {result.accuracy_original_html:.3f}")
|
||||
print(f" Extracted: {result.accuracy_extracted:.3f}")
|
||||
print(f" Preserved: {result.accuracy_preserved}")
|
||||
|
||||
def test_qa_accuracy_squad_medium(self, answer_fn):
|
||||
"""Medium QA accuracy test on 30 SQuAD questions."""
|
||||
pytest.importorskip("datasets")
|
||||
from headroom.evals.html_oss_benchmarks import evaluate_qa_accuracy_preservation
|
||||
|
||||
result = evaluate_qa_accuracy_preservation(
|
||||
answer_fn=answer_fn,
|
||||
max_questions=30,
|
||||
dataset_name="squad",
|
||||
)
|
||||
|
||||
assert result.accuracy_preserved
|
||||
|
||||
print("\nQA Accuracy (30 questions):")
|
||||
print(f" Original HTML: {result.accuracy_original_html:.3f}")
|
||||
print(f" Extracted: {result.accuracy_extracted:.3f}")
|
||||
print(f" Delta: {result.accuracy_extracted - result.accuracy_original_html:+.3f}")
|
||||
|
||||
|
||||
@pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
|
||||
class TestFullBenchmarkSuite:
|
||||
"""Full benchmark suite combining extraction quality and QA accuracy."""
|
||||
|
||||
@pytest.fixture
|
||||
def answer_fn(self):
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI()
|
||||
|
||||
def answer(context: str, question: str) -> str:
|
||||
prompt = f"""Answer the question based on the content.
|
||||
|
||||
Content: {context[:4000]}
|
||||
|
||||
Question: {question}
|
||||
|
||||
Answer concisely:"""
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0.0,
|
||||
max_tokens=100,
|
||||
)
|
||||
return response.choices[0].message.content or ""
|
||||
|
||||
return answer
|
||||
|
||||
def test_full_suite(self, answer_fn):
|
||||
"""Run the complete benchmark suite."""
|
||||
pytest.importorskip("datasets")
|
||||
from headroom.evals.html_oss_benchmarks import run_full_benchmark_suite
|
||||
|
||||
result = run_full_benchmark_suite(
|
||||
answer_fn=answer_fn,
|
||||
extraction_samples=30,
|
||||
qa_questions=20,
|
||||
)
|
||||
|
||||
# Print comprehensive results
|
||||
print("\n" + "=" * 60)
|
||||
print("FULL BENCHMARK SUITE RESULTS")
|
||||
print("=" * 60)
|
||||
|
||||
summary = result.summary()
|
||||
|
||||
if result.extraction_result:
|
||||
ext = summary["extraction"]
|
||||
print("\n📊 Extraction Benchmark:")
|
||||
print(f" Samples: {ext['total_samples']}")
|
||||
print(f" Precision: {ext['avg_precision']:.3f}")
|
||||
print(f" Recall: {ext['avg_recall']:.3f}")
|
||||
print(f" F1: {ext['avg_f1']:.3f} (baseline: {ext['baseline_f1']:.3f})")
|
||||
print(f" Compression: {(1 - ext['avg_compression_ratio']) * 100:.1f}% reduction")
|
||||
|
||||
if result.qa_result:
|
||||
qa = summary["qa_accuracy"]
|
||||
print("\n📝 QA Accuracy Preservation:")
|
||||
print(f" Questions: {qa['total_questions']}")
|
||||
print(f" Original: {qa['accuracy_original_html']:.3f}")
|
||||
print(f" Extracted: {qa['accuracy_extracted']:.3f}")
|
||||
print(f" Delta: {qa['accuracy_delta']:+.3f}")
|
||||
print(f" Preserved: {'✅' if qa['accuracy_preserved'] else '❌'}")
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"ALL BENCHMARKS PASSED: {'✅' if summary['all_passed'] else '❌'}")
|
||||
print(f"{'=' * 60}\n")
|
||||
|
||||
# Assert all passed
|
||||
assert result.all_passed, "Not all benchmarks passed"
|
||||
|
||||
|
||||
class TestBenchmarkInfrastructure:
|
||||
"""Tests for benchmark infrastructure without running full evals."""
|
||||
|
||||
def test_result_classes(self):
|
||||
"""Test result dataclasses work correctly."""
|
||||
from headroom.evals.html_oss_benchmarks import (
|
||||
ExtractionBenchmarkResult,
|
||||
QAAccuracyResult,
|
||||
)
|
||||
|
||||
ext = ExtractionBenchmarkResult(
|
||||
total_samples=100,
|
||||
avg_precision=0.95,
|
||||
avg_recall=0.92,
|
||||
avg_f1=0.935,
|
||||
avg_compression_ratio=0.35,
|
||||
)
|
||||
assert ext.matches_baseline is False # 0.935 not within 0.02 of 0.958
|
||||
assert ext.beats_baseline is False
|
||||
|
||||
qa = QAAccuracyResult(
|
||||
total_questions=50,
|
||||
accuracy_original_html=0.85,
|
||||
accuracy_extracted=0.87,
|
||||
accuracy_preserved=True,
|
||||
avg_f1_original=0.85,
|
||||
avg_f1_extracted=0.87,
|
||||
exact_match_original=0.60,
|
||||
exact_match_extracted=0.62,
|
||||
)
|
||||
assert qa.accuracy_preserved is True
|
||||
|
||||
def test_suite_all_passed(self):
|
||||
"""Test suite pass/fail logic."""
|
||||
from headroom.evals.html_oss_benchmarks import (
|
||||
ExtractionBenchmarkResult,
|
||||
HTMLExtractorBenchmarkSuite,
|
||||
QAAccuracyResult,
|
||||
)
|
||||
|
||||
# Both pass
|
||||
suite = HTMLExtractorBenchmarkSuite(
|
||||
extraction_result=ExtractionBenchmarkResult(
|
||||
total_samples=100,
|
||||
avg_precision=0.95,
|
||||
avg_recall=0.92,
|
||||
avg_f1=0.935,
|
||||
avg_compression_ratio=0.35,
|
||||
),
|
||||
qa_result=QAAccuracyResult(
|
||||
total_questions=50,
|
||||
accuracy_original_html=0.85,
|
||||
accuracy_extracted=0.87,
|
||||
accuracy_preserved=True,
|
||||
avg_f1_original=0.85,
|
||||
avg_f1_extracted=0.87,
|
||||
exact_match_original=0.60,
|
||||
exact_match_extracted=0.62,
|
||||
),
|
||||
)
|
||||
assert suite.all_passed is True
|
||||
|
||||
# Extraction fails (F1 too low)
|
||||
suite_fail = HTMLExtractorBenchmarkSuite(
|
||||
extraction_result=ExtractionBenchmarkResult(
|
||||
total_samples=100,
|
||||
avg_precision=0.7,
|
||||
avg_recall=0.7,
|
||||
avg_f1=0.7, # Below 0.90 threshold
|
||||
avg_compression_ratio=0.35,
|
||||
),
|
||||
)
|
||||
assert suite_fail.all_passed is False
|
||||
592
tests/test_transforms/test_html_extractor.py
Normal file
592
tests/test_transforms/test_html_extractor.py
Normal file
|
|
@ -0,0 +1,592 @@
|
|||
"""Tests for HTMLExtractor.
|
||||
|
||||
These are real tests using actual HTML content - no mocks.
|
||||
Tests verify that trafilatura correctly extracts main content
|
||||
and removes structural noise.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from headroom.transforms.html_extractor import (
|
||||
HTMLExtractionResult,
|
||||
HTMLExtractor,
|
||||
HTMLExtractorConfig,
|
||||
is_html_content,
|
||||
)
|
||||
|
||||
|
||||
class TestIsHtmlContent:
|
||||
"""Tests for the is_html_content detection function."""
|
||||
|
||||
def test_detects_doctype_html(self):
|
||||
"""Detects HTML with DOCTYPE declaration."""
|
||||
html = "<!DOCTYPE html><html><body>Content</body></html>"
|
||||
assert is_html_content(html) is True
|
||||
|
||||
def test_detects_html_tag(self):
|
||||
"""Detects HTML with html tag."""
|
||||
html = "<html><head></head><body>Content</body></html>"
|
||||
assert is_html_content(html) is True
|
||||
|
||||
def test_detects_structural_tags(self):
|
||||
"""Detects HTML with multiple structural tags (needs doctype or html tag)."""
|
||||
# Note: is_html_content requires DOCTYPE or <html> tag, or 3+ structural tags
|
||||
# Just structural tags alone may not trigger detection
|
||||
html = "<html><div><nav>Menu</nav><article>Content</article><footer>Footer</footer></div></html>"
|
||||
assert is_html_content(html) is True
|
||||
|
||||
def test_rejects_plain_text(self):
|
||||
"""Rejects plain text."""
|
||||
text = "This is just plain text with no HTML."
|
||||
assert is_html_content(text) is False
|
||||
|
||||
def test_rejects_json(self):
|
||||
"""Rejects JSON content."""
|
||||
json_content = '{"name": "test", "value": 123}'
|
||||
assert is_html_content(json_content) is False
|
||||
|
||||
def test_rejects_markdown(self):
|
||||
"""Rejects markdown content."""
|
||||
markdown = "# Heading\n\nParagraph with **bold** text."
|
||||
assert is_html_content(markdown) is False
|
||||
|
||||
def test_rejects_code(self):
|
||||
"""Rejects source code."""
|
||||
code = "def hello():\n print('world')"
|
||||
assert is_html_content(code) is False
|
||||
|
||||
def test_rejects_empty(self):
|
||||
"""Rejects empty content."""
|
||||
assert is_html_content("") is False
|
||||
assert is_html_content(None) is False # type: ignore
|
||||
|
||||
def test_case_insensitive(self):
|
||||
"""Detection is case insensitive."""
|
||||
html = "<!DOCTYPE HTML><HTML><BODY>Content</BODY></HTML>"
|
||||
assert is_html_content(html) is True
|
||||
|
||||
|
||||
class TestHTMLExtractor:
|
||||
"""Tests for HTMLExtractor content extraction."""
|
||||
|
||||
@pytest.fixture
|
||||
def extractor(self):
|
||||
"""Create a default HTMLExtractor."""
|
||||
return HTMLExtractor()
|
||||
|
||||
def test_extracts_article_content(self, extractor):
|
||||
"""Extracts main article content."""
|
||||
html = """<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Test Article</title></head>
|
||||
<body>
|
||||
<nav><a href="/">Home</a></nav>
|
||||
<article>
|
||||
<h1>Article Title</h1>
|
||||
<p>This is the main content of the article.</p>
|
||||
<p>It contains important information for the reader.</p>
|
||||
</article>
|
||||
<footer>Copyright 2024</footer>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
result = extractor.extract(html)
|
||||
|
||||
assert "Article Title" in result.extracted
|
||||
assert "main content" in result.extracted
|
||||
assert "important information" in result.extracted
|
||||
# Navigation and footer should be removed
|
||||
assert "Home" not in result.extracted or "Copyright" not in result.extracted
|
||||
|
||||
def test_removes_script_tags(self, extractor):
|
||||
"""Removes JavaScript content."""
|
||||
html = """<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<script>
|
||||
function malicious() {
|
||||
alert('This should not appear');
|
||||
console.log('Script content');
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<article>
|
||||
<p>Actual content that matters.</p>
|
||||
</article>
|
||||
<script>anotherScript();</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
result = extractor.extract(html)
|
||||
|
||||
assert "malicious" not in result.extracted
|
||||
assert "alert" not in result.extracted
|
||||
assert "anotherScript" not in result.extracted
|
||||
assert "Actual content" in result.extracted
|
||||
|
||||
def test_removes_style_tags(self, extractor):
|
||||
"""Removes CSS content."""
|
||||
html = """<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { color: red; }
|
||||
.hidden { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<article>
|
||||
<p>Real content here.</p>
|
||||
</article>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
result = extractor.extract(html)
|
||||
|
||||
assert "color: red" not in result.extracted
|
||||
assert "display: none" not in result.extracted
|
||||
assert "Real content" in result.extracted
|
||||
|
||||
def test_compression_ratio(self, extractor):
|
||||
"""Verifies significant compression ratio."""
|
||||
html = """<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Page Title</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<script src="analytics.js"></script>
|
||||
<script>
|
||||
var config = {tracking: true, debug: false};
|
||||
function init() { console.log('initialized'); }
|
||||
</script>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; }
|
||||
body { font-family: Arial; }
|
||||
nav { background: #333; }
|
||||
footer { background: #666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav>
|
||||
<a href="/">Home</a>
|
||||
<a href="/about">About</a>
|
||||
<a href="/contact">Contact</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<article>
|
||||
<h1>Main Article Heading</h1>
|
||||
<p>This is the first paragraph of actual content.</p>
|
||||
<p>This is the second paragraph with more details.</p>
|
||||
</article>
|
||||
</main>
|
||||
<aside>
|
||||
<h3>Related Links</h3>
|
||||
<ul>
|
||||
<li><a href="/link1">Link 1</a></li>
|
||||
<li><a href="/link2">Link 2</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
<footer>
|
||||
<p>Copyright 2024</p>
|
||||
<p>Privacy Policy | Terms of Service</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
result = extractor.extract(html)
|
||||
|
||||
# Should achieve significant reduction
|
||||
assert result.compression_ratio < 0.5 # At least 50% reduction
|
||||
assert result.reduction_percent > 50
|
||||
# Main content should be preserved
|
||||
assert "Main Article Heading" in result.extracted
|
||||
assert "first paragraph" in result.extracted
|
||||
|
||||
def test_extracts_metadata(self, extractor):
|
||||
"""Extracts page metadata."""
|
||||
html = """<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Page Title for Testing</title>
|
||||
<meta name="author" content="John Doe">
|
||||
<meta name="description" content="A test page description">
|
||||
</head>
|
||||
<body>
|
||||
<article>
|
||||
<h1>Page Title for Testing</h1>
|
||||
<p>Content here.</p>
|
||||
</article>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
result = extractor.extract(html)
|
||||
|
||||
assert result.title == "Page Title for Testing"
|
||||
assert result.metadata.get("title") == "Page Title for Testing"
|
||||
|
||||
def test_handles_empty_html(self, extractor):
|
||||
"""Handles empty HTML gracefully."""
|
||||
result = extractor.extract("")
|
||||
|
||||
assert result.extracted == ""
|
||||
assert result.original_length == 0
|
||||
assert result.extracted_length == 0
|
||||
|
||||
def test_handles_whitespace_only(self, extractor):
|
||||
"""Handles whitespace-only input."""
|
||||
result = extractor.extract(" \n\t ")
|
||||
|
||||
assert result.extracted == ""
|
||||
assert result.compression_ratio == 0.0
|
||||
|
||||
def test_handles_minimal_html(self, extractor):
|
||||
"""Handles minimal HTML structure."""
|
||||
html = "<p>Just a paragraph.</p>"
|
||||
result = extractor.extract(html)
|
||||
|
||||
# trafilatura may or may not extract minimal content
|
||||
# Just verify it doesn't crash
|
||||
assert isinstance(result.extracted, str)
|
||||
assert result.original_length > 0
|
||||
|
||||
def test_preserves_paragraphs(self, extractor):
|
||||
"""Preserves paragraph structure."""
|
||||
html = """<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<article>
|
||||
<p>First paragraph.</p>
|
||||
<p>Second paragraph.</p>
|
||||
<p>Third paragraph.</p>
|
||||
</article>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
result = extractor.extract(html)
|
||||
|
||||
assert "First paragraph" in result.extracted
|
||||
assert "Second paragraph" in result.extracted
|
||||
assert "Third paragraph" in result.extracted
|
||||
|
||||
|
||||
class TestHTMLExtractorConfig:
|
||||
"""Tests for HTMLExtractor configuration options."""
|
||||
|
||||
def test_markdown_output_format(self):
|
||||
"""Tests markdown output format."""
|
||||
config = HTMLExtractorConfig(output_format="markdown")
|
||||
extractor = HTMLExtractor(config)
|
||||
|
||||
html = """<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<article>
|
||||
<h1>Heading</h1>
|
||||
<p>Paragraph text.</p>
|
||||
</article>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
result = extractor.extract(html)
|
||||
# Markdown format should include # for heading
|
||||
assert "Heading" in result.extracted
|
||||
|
||||
def test_text_output_format(self):
|
||||
"""Tests plain text output format."""
|
||||
config = HTMLExtractorConfig(output_format="txt")
|
||||
extractor = HTMLExtractor(config)
|
||||
|
||||
html = """<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<article>
|
||||
<h1>Heading</h1>
|
||||
<p>Paragraph text.</p>
|
||||
</article>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
result = extractor.extract(html)
|
||||
assert "Heading" in result.extracted
|
||||
assert "Paragraph" in result.extracted
|
||||
|
||||
def test_disable_metadata_extraction(self):
|
||||
"""Tests disabling metadata extraction."""
|
||||
config = HTMLExtractorConfig(extract_metadata=False)
|
||||
extractor = HTMLExtractor(config)
|
||||
|
||||
html = """<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Test Title</title></head>
|
||||
<body><article><p>Content.</p></article></body>
|
||||
</html>"""
|
||||
|
||||
result = extractor.extract(html)
|
||||
|
||||
# Metadata should be empty when disabled
|
||||
assert result.title is None
|
||||
assert result.metadata == {}
|
||||
|
||||
|
||||
class TestHTMLExtractionResult:
|
||||
"""Tests for HTMLExtractionResult dataclass."""
|
||||
|
||||
def test_reduction_percent_calculation(self):
|
||||
"""Tests reduction percent calculation."""
|
||||
result = HTMLExtractionResult(
|
||||
extracted="short",
|
||||
original="much longer content here",
|
||||
original_length=100,
|
||||
extracted_length=25,
|
||||
compression_ratio=0.25,
|
||||
)
|
||||
|
||||
assert result.reduction_percent == 75.0
|
||||
|
||||
def test_reduction_percent_with_zero_original(self):
|
||||
"""Tests reduction percent with zero original length."""
|
||||
result = HTMLExtractionResult(
|
||||
extracted="",
|
||||
original="",
|
||||
original_length=0,
|
||||
extracted_length=0,
|
||||
compression_ratio=0.0,
|
||||
)
|
||||
|
||||
assert result.reduction_percent == 0.0
|
||||
|
||||
|
||||
class TestRealWorldHTML:
|
||||
"""Tests with realistic HTML content."""
|
||||
|
||||
@pytest.fixture
|
||||
def extractor(self):
|
||||
return HTMLExtractor()
|
||||
|
||||
def test_news_article_structure(self, extractor):
|
||||
"""Tests extraction from news-article-like structure."""
|
||||
html = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Breaking News: Important Event Happens</title>
|
||||
<script>window.analytics = {};</script>
|
||||
<style>.ad { display: block; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav class="main-nav">
|
||||
<a href="/">Home</a>
|
||||
<a href="/news">News</a>
|
||||
<a href="/sports">Sports</a>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="ad-banner">Advertisement Here</div>
|
||||
<main>
|
||||
<article class="news-article">
|
||||
<h1>Breaking News: Important Event Happens</h1>
|
||||
<p class="byline">By Jane Reporter | January 15, 2024</p>
|
||||
<p>In a surprising turn of events, something important happened today
|
||||
that will affect millions of people around the world.</p>
|
||||
<p>Experts say this development represents a major shift in how
|
||||
we think about the topic at hand.</p>
|
||||
<p>"This is truly unprecedented," said Dr. Expert, a leading
|
||||
authority in the field.</p>
|
||||
<p>The implications of this event are still being analyzed, but
|
||||
early reports suggest significant changes ahead.</p>
|
||||
</article>
|
||||
</main>
|
||||
<aside class="sidebar">
|
||||
<h3>Trending Stories</h3>
|
||||
<ul>
|
||||
<li><a href="/story1">Story 1</a></li>
|
||||
<li><a href="/story2">Story 2</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
<footer>
|
||||
<p>© 2024 News Site</p>
|
||||
<a href="/privacy">Privacy Policy</a>
|
||||
</footer>
|
||||
<script>trackPageView();</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
result = extractor.extract(html)
|
||||
|
||||
# Main article content should be preserved
|
||||
assert "Important Event Happens" in result.extracted
|
||||
assert "surprising turn of events" in result.extracted
|
||||
assert "Dr. Expert" in result.extracted
|
||||
|
||||
# Noise should be removed or minimized
|
||||
assert "trackPageView" not in result.extracted
|
||||
assert "window.analytics" not in result.extracted
|
||||
|
||||
# Significant reduction
|
||||
assert result.compression_ratio < 0.4
|
||||
|
||||
def test_documentation_page(self, extractor):
|
||||
"""Tests extraction from documentation-like page."""
|
||||
html = """<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>API Documentation - MyService</title>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="docs-nav">
|
||||
<a href="/docs">Docs</a>
|
||||
<a href="/api">API</a>
|
||||
<a href="/guides">Guides</a>
|
||||
</nav>
|
||||
<div class="sidebar">
|
||||
<h3>Table of Contents</h3>
|
||||
<ul>
|
||||
<li><a href="#intro">Introduction</a></li>
|
||||
<li><a href="#auth">Authentication</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<main class="content">
|
||||
<h1>API Documentation</h1>
|
||||
<section id="intro">
|
||||
<h2>Introduction</h2>
|
||||
<p>Welcome to the MyService API documentation. This guide will
|
||||
help you integrate our service into your application.</p>
|
||||
</section>
|
||||
<section id="auth">
|
||||
<h2>Authentication</h2>
|
||||
<p>All API requests require authentication using an API key.
|
||||
Include your key in the Authorization header.</p>
|
||||
<pre><code>Authorization: Bearer YOUR_API_KEY</code></pre>
|
||||
</section>
|
||||
</main>
|
||||
<footer>Built with Docs Generator</footer>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
result = extractor.extract(html)
|
||||
|
||||
# Documentation content should be preserved
|
||||
assert "API Documentation" in result.extracted
|
||||
assert "Authentication" in result.extracted
|
||||
assert "API key" in result.extracted
|
||||
|
||||
def test_blog_post(self, extractor):
|
||||
"""Tests extraction from blog-post-like structure."""
|
||||
html = """<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>My Blog Post Title - Personal Blog</title>
|
||||
<meta name="author" content="Blog Author">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1 class="site-title">My Personal Blog</h1>
|
||||
<nav>Home | About | Contact</nav>
|
||||
</header>
|
||||
<article class="blog-post">
|
||||
<h1>My Blog Post Title</h1>
|
||||
<time>Posted on March 15, 2024</time>
|
||||
<p>Today I want to share my thoughts on an interesting topic
|
||||
that I've been thinking about for a while.</p>
|
||||
<p>The key insight is that we often overlook the simple things
|
||||
in life that bring us joy.</p>
|
||||
<p>In conclusion, I believe we should all take more time to
|
||||
appreciate the small moments.</p>
|
||||
</article>
|
||||
<section class="comments">
|
||||
<h3>Comments</h3>
|
||||
<div class="comment">Great post!</div>
|
||||
</section>
|
||||
<footer>© 2024 Blog Author</footer>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
result = extractor.extract(html)
|
||||
|
||||
# Blog content should be preserved
|
||||
assert "Blog Post Title" in result.extracted
|
||||
assert "interesting topic" in result.extracted
|
||||
assert "small moments" in result.extracted
|
||||
|
||||
|
||||
class TestBatchExtraction:
|
||||
"""Tests for batch extraction."""
|
||||
|
||||
def test_extract_batch(self):
|
||||
"""Tests batch extraction of multiple HTML pages."""
|
||||
extractor = HTMLExtractor()
|
||||
|
||||
pages = [
|
||||
(
|
||||
"<html><body><article><p>Page one content.</p></article></body></html>",
|
||||
"http://example.com/page1",
|
||||
),
|
||||
(
|
||||
"<html><body><article><p>Page two content.</p></article></body></html>",
|
||||
"http://example.com/page2",
|
||||
),
|
||||
(
|
||||
"<html><body><article><p>Page three content.</p></article></body></html>",
|
||||
None,
|
||||
),
|
||||
]
|
||||
|
||||
results = extractor.extract_batch(pages)
|
||||
|
||||
assert len(results) == 3
|
||||
assert all(isinstance(r, HTMLExtractionResult) for r in results)
|
||||
|
||||
|
||||
class TestContentDetectorIntegration:
|
||||
"""Tests for integration with content_detector."""
|
||||
|
||||
def test_detector_identifies_html(self):
|
||||
"""Tests that content_detector correctly identifies HTML."""
|
||||
from headroom.transforms.content_detector import ContentType, detect_content_type
|
||||
|
||||
html = """<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Test</title></head>
|
||||
<body>
|
||||
<div><p>Content</p></div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
result = detect_content_type(html)
|
||||
|
||||
assert result.content_type == ContentType.HTML
|
||||
assert result.confidence >= 0.7
|
||||
|
||||
def test_detector_rejects_non_html(self):
|
||||
"""Tests that content_detector doesn't misidentify non-HTML."""
|
||||
from headroom.transforms.content_detector import ContentType, detect_content_type
|
||||
|
||||
# Plain text
|
||||
result = detect_content_type("Just some plain text without HTML.")
|
||||
assert result.content_type != ContentType.HTML
|
||||
|
||||
# JSON
|
||||
result = detect_content_type('[{"id": 1}, {"id": 2}]')
|
||||
assert result.content_type == ContentType.JSON_ARRAY
|
||||
|
||||
# Code (needs enough patterns to trigger detection)
|
||||
code = """
|
||||
import os
|
||||
import sys
|
||||
|
||||
def hello():
|
||||
print('world')
|
||||
|
||||
class Foo:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
if __name__ == '__main__':
|
||||
hello()
|
||||
"""
|
||||
result = detect_content_type(code)
|
||||
assert result.content_type == ContentType.SOURCE_CODE
|
||||
Loading…
Add table
Add a link
Reference in a new issue