graph
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
from crawler_platform.app.config.loader import ProjectConfig
|
||||
from crawler_platform.app.core.extractor.base import ExtractedClaim, ExtractionBundle
|
||||
from crawler_platform.app.core.extractor.validation import validate_extraction_bundle
|
||||
from crawler_platform.app.domains.perfume.extractor import PerfumeRuleBasedExtractor
|
||||
|
||||
|
||||
def perfume_config() -> ProjectConfig:
|
||||
return ProjectConfig(
|
||||
project_name="perfume-test",
|
||||
domain="perfume",
|
||||
target_entities=["Perfume"],
|
||||
fields=[],
|
||||
sources=[],
|
||||
ontology={
|
||||
"entity_types": [
|
||||
"Perfume",
|
||||
"Product",
|
||||
"Brand",
|
||||
"Note",
|
||||
"Accord",
|
||||
"Mood",
|
||||
"Season",
|
||||
"Occasion",
|
||||
"Review",
|
||||
],
|
||||
"predicates": ["hasBrand", "hasPrice"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_forment_product_price_line_is_not_extracted_as_brand() -> None:
|
||||
text = "\n".join(
|
||||
[
|
||||
"포맨트 시그니처 퍼퓸 코튼메모리",
|
||||
"62,000원",
|
||||
"49,000원",
|
||||
"기억의 표면을 어루만지다",
|
||||
]
|
||||
)
|
||||
|
||||
bundle = PerfumeRuleBasedExtractor().extract(text, perfume_config())
|
||||
|
||||
brands = [entity.name for entity in bundle.entities if entity.entity_type == "Brand"]
|
||||
assert brands == ["FORMENT"]
|
||||
assert "62,000원" not in brands
|
||||
assert any(
|
||||
claim.predicate == "hasBrand"
|
||||
and claim.object_name == "FORMENT"
|
||||
and claim.evidence_text == "포맨트 시그니처 퍼퓸 코튼메모리"
|
||||
for claim in bundle.claims
|
||||
)
|
||||
assert any(
|
||||
claim.predicate == "hasPrice"
|
||||
and claim.object_value == {"amount": 62000.0, "currency": "원"}
|
||||
for claim in bundle.claims
|
||||
)
|
||||
|
||||
|
||||
def test_validation_rejects_price_like_brand_object() -> None:
|
||||
bundle = ExtractionBundle(
|
||||
claims=[
|
||||
ExtractedClaim(
|
||||
"포맨트 시그니처 퍼퓸 코튼메모리",
|
||||
"Perfume",
|
||||
"hasBrand",
|
||||
"62,000원",
|
||||
"Brand",
|
||||
evidence_text="포맨트 시그니처 퍼퓸 코튼메모리 62,000원",
|
||||
confidence=0.9,
|
||||
)
|
||||
],
|
||||
extractor_name="rule_only_extractor",
|
||||
raw_output={"extraction_mode": "rule_only"},
|
||||
)
|
||||
|
||||
result = validate_extraction_bundle(bundle, perfume_config())
|
||||
|
||||
assert result.bundle.claims == []
|
||||
assert result.rejected_claims[0]["reason"] == "brand object looks like a price"
|
||||
144
ontology_platform/tests/unit/test_phase7_hybrid_extraction.py
Normal file
144
ontology_platform/tests/unit/test_phase7_hybrid_extraction.py
Normal file
@@ -0,0 +1,144 @@
|
||||
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"]
|
||||
Reference in New Issue
Block a user