0ef5fcb1c5
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
312 lines
11 KiB
Python
312 lines
11 KiB
Python
"""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
|
|
|
|
# Skip entire module if trafilatura not installed
|
|
pytest.importorskip("trafilatura")
|
|
|
|
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 Kompress 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="Kompress requires GPU, skip in CI")
|
|
def test_extraction_beats_baseline(self, evaluator_with_baseline):
|
|
"""Test that HTMLExtractor outperforms Kompress 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 Kompress on HTML content
|
|
assert results.avg_extraction_score >= results.avg_baseline_score, (
|
|
"HTMLExtractor should perform at least as well as Kompress 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")
|