145 lines
5.3 KiB
Python
145 lines
5.3 KiB
Python
from crawler_platform.app.config.loader import ProjectConfig
|
|
from crawler_platform.app.core.extractor.base import ExtractedClaim, ExtractionBundle, ExtractionPageContext
|
|
from crawler_platform.app.core.extractor.hybrid import HybridExtractor, mark_bundle, merge_bundles
|
|
from crawler_platform.app.core.extractor.validation import validate_extraction_bundle
|
|
|
|
|
|
def project_config() -> ProjectConfig:
|
|
return ProjectConfig(
|
|
project_name="phase7-test",
|
|
domain="generic",
|
|
target_entities=["Thing"],
|
|
fields=[],
|
|
sources=[],
|
|
ontology={
|
|
"entity_types": ["Thing"],
|
|
"predicates": ["hasPart", "color", "size"],
|
|
"relation_types": {
|
|
"hasPart": {
|
|
"allowed_subject_types": ["Thing"],
|
|
"allowed_object_types": ["Thing"],
|
|
"confidence_rules": {"min_confidence": 0.1},
|
|
},
|
|
"color": {
|
|
"allowed_subject_types": ["Thing"],
|
|
"literal_value": True,
|
|
"confidence_rules": {"min_confidence": 0.1},
|
|
},
|
|
"size": {
|
|
"allowed_subject_types": ["Thing"],
|
|
"literal_value": True,
|
|
"confidence_rules": {"min_confidence": 0.1},
|
|
},
|
|
},
|
|
},
|
|
)
|
|
|
|
|
|
def test_hybrid_merge_marks_agreement_conflict_and_counts() -> None:
|
|
rule = ExtractionBundle(
|
|
claims=[
|
|
ExtractedClaim("A", "Thing", "hasPart", "B", "Thing", confidence=0.7),
|
|
ExtractedClaim("A", "Thing", "color", object_value="red", confidence=0.6),
|
|
]
|
|
)
|
|
llm = ExtractionBundle(
|
|
claims=[
|
|
ExtractedClaim("A", "Thing", "hasPart", "B", "Thing", confidence=0.8),
|
|
ExtractedClaim("A", "Thing", "color", object_value="blue", confidence=0.65),
|
|
ExtractedClaim("A", "Thing", "size", object_value="large", confidence=0.5),
|
|
]
|
|
)
|
|
mark_bundle(rule, source="rule", mode="compare")
|
|
mark_bundle(llm, source="llm", mode="compare")
|
|
|
|
merged = merge_bundles(rule, llm, HybridExtractor("generic", mode="compare"))
|
|
|
|
assert merged.extractor_name == "compare_rule_llm_extractor"
|
|
assert merged.raw_output["comparison"] == {
|
|
"both_agree": 1,
|
|
"rule_only": 0,
|
|
"llm_only": 1,
|
|
"conflict": 1,
|
|
"rejected_by_validation": 0,
|
|
}
|
|
by_predicate = {(claim.predicate, claim.object_name or claim.object_value): claim for claim in merged.claims}
|
|
assert by_predicate[("hasPart", "B")].metadata["agreement"] == "rule_and_llm"
|
|
assert by_predicate[("color", "red")].metadata["review_required"] is True
|
|
assert by_predicate[("color", "blue")].metadata["conflict_status"] == "rule_llm_conflict"
|
|
assert by_predicate[("size", "large")].metadata["agreement"] == "llm_only"
|
|
|
|
|
|
def test_validation_keeps_evidence_missing_llm_only_claim_as_candidate() -> None:
|
|
bundle = ExtractionBundle(
|
|
claims=[
|
|
ExtractedClaim(
|
|
"A",
|
|
"Thing",
|
|
"color",
|
|
object_value="blue",
|
|
confidence=0.8,
|
|
metadata={
|
|
"agreement": "llm_only",
|
|
"extraction_source": "llm",
|
|
"llm_confidence": 0.8,
|
|
},
|
|
)
|
|
],
|
|
extractor_name="hybrid_rule_llm_extractor",
|
|
provider="lm_studio",
|
|
raw_output={"extraction_mode": "hybrid"},
|
|
)
|
|
|
|
result = validate_extraction_bundle(bundle, project_config())
|
|
|
|
assert len(result.bundle.claims) == 1
|
|
claim = result.bundle.claims[0]
|
|
assert claim.metadata["validation_status"] == "candidate_claim"
|
|
assert claim.metadata["review_required"] is True
|
|
assert claim.metadata["review_reason"] == "claim has no evidence"
|
|
|
|
|
|
def test_validation_marks_rule_only_claim_as_rule_candidate() -> None:
|
|
bundle = ExtractionBundle(
|
|
claims=[
|
|
ExtractedClaim(
|
|
"Alpha",
|
|
"Thing",
|
|
"color",
|
|
object_value="red",
|
|
evidence_text="Alpha color red",
|
|
confidence=0.7,
|
|
metadata={
|
|
"agreement": "rule_only",
|
|
"extraction_source": "rule",
|
|
"rule_confidence": 0.7,
|
|
},
|
|
)
|
|
],
|
|
extractor_name="hybrid_rule_llm_extractor",
|
|
provider="lm_studio",
|
|
raw_output={"extraction_mode": "hybrid"},
|
|
)
|
|
|
|
result = validate_extraction_bundle(bundle, project_config())
|
|
|
|
assert result.bundle.claims[0].metadata["validation_status"] == "rule_candidate"
|
|
assert result.bundle.claims[0].metadata["claim_kind"] == "rule_candidate"
|
|
|
|
|
|
def test_hybrid_smart_routing_skips_llm_for_category_page() -> None:
|
|
context = ExtractionPageContext(
|
|
url="https://example.test/category",
|
|
final_url=None,
|
|
title="Category",
|
|
page_type="CategoryPage",
|
|
clean_text="Category listing " * 40,
|
|
)
|
|
|
|
bundle = HybridExtractor("generic", mode="hybrid").extract_from_context(context, project_config())
|
|
|
|
assert bundle.extractor_name == "hybrid_rule_only_routed"
|
|
assert bundle.raw_output["llm_skipped"] is True
|
|
assert bundle.raw_output["effective_extraction_mode"] == "rule_only"
|
|
assert "CategoryPage" in bundle.raw_output["llm_skip_reason"]
|