This commit is contained in:
LASTA_DEV01\lasta
2026-05-19 20:31:52 +09:00
parent 00407e7a08
commit e260e5f218
104 changed files with 12898 additions and 1709 deletions

View File

@@ -0,0 +1,91 @@
from __future__ import annotations
from ont_platform.core.extraction.lightweight_extractor import ExtractionResult
from ont_platform.storage.candidate_repository import CandidateRepository
from ont_platform.storage.models import Base, CandidateSource, ReviewStatus
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
def _session() -> Session:
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
return sessionmaker(bind=engine)()
def test_repository_saves_lightweight_candidates_with_evidence() -> None:
db = _session()
repository = CandidateRepository(db)
result = ExtractionResult(
entities=[
{
"id": "E_alice",
"label": "Alice",
"type": "person",
"confidence": 0.91,
"evidence_ids": ["EV_1"],
}
],
relations=[],
evidence_spans=[
{
"id": "EV_1",
"text": "Alice works at Acme.",
"start_offset": 0,
"end_offset": 20,
}
],
warnings=[],
)
batch = repository.save_lightweight_result(
project_id="proj_1",
document_id="doc_1",
result=result,
source_trust=0.8,
validation_passed=True,
)
assert len(batch.entities) == 1
entity = batch.entities[0]
assert entity.source_type == CandidateSource.LIGHTWEIGHT
assert entity.review_status == ReviewStatus.PENDING
assert entity.evidence_ids == ["EV_1"]
assert repository.candidate_has_valid_evidence(entity)
def test_repository_saves_ontocast_candidates_on_separate_source_path() -> None:
db = _session()
repository = CandidateRepository(db)
batch = repository.save_ontocast_result(
project_id="proj_1",
document_id="doc_1",
result={
"entities": [
{
"id": "E_graph",
"label": "GraphUpdate",
"entity_type": "concept",
"confidence": 0.72,
"evidence_ids": ["EV_graph"],
}
],
"relations": [],
"evidence_spans": [
{
"id": "EV_graph",
"text": "OntoCast proposed a graph update.",
"start_offset": 0,
"end_offset": 34,
}
],
},
source_trust=0.7,
validation_passed=False,
)
entity = batch.entities[0]
assert entity.source_type == CandidateSource.ONTOCAST
assert entity.created_by == "ontocast"
assert entity.validation_passed is False
assert entity.metadata_["source_type"] == "ontocast"

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
from pathlib import Path
from ont_platform.core.extractors.web_extractor import extract_web_content
from ont_platform.models.content_unit import PlatformContentUnit
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "korean"
def test_platform_content_unit_wraps_ontocast_unit_without_mutating_core() -> None:
html = (FIXTURES / "shop_coupang.html").read_text(encoding="utf-8")
extracted = extract_web_content(html=html, url="https://example.test/shop/data-quality-tool")
unit = PlatformContentUnit.from_extracted(extracted)
ontocast_unit = unit.as_ontocast()
assert unit.source_url == "https://example.test/shop/data-quality-tool"
assert unit.content_hash == extracted.content_hash
assert ontocast_unit.text == extracted.text
assert str(ontocast_unit.doc_iri).startswith("urn:source:doc_")

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from ont_platform.storage.dedup_cache import InMemoryDedupCache
def test_dedup_cache_remembers_project_scoped_fingerprint() -> None:
cache = InMemoryDedupCache()
first = cache.check_and_remember(
project_id="proj_1",
document_id="doc_a",
content_hash="hash-a",
fingerprint="fingerprint-a",
)
second = cache.check_and_remember(
project_id="proj_1",
document_id="doc_b",
content_hash="hash-b",
fingerprint="fingerprint-a",
)
other_project = cache.check_and_remember(
project_id="proj_2",
document_id="doc_c",
content_hash="hash-c",
fingerprint="fingerprint-a",
)
assert first.is_duplicate is False
assert second.is_duplicate is True
assert second.existing_document_id == "doc_a"
assert other_project.is_duplicate is False
assert len(cache) == 2

View File

@@ -0,0 +1,90 @@
"""Phase 4 validation gate tests."""
from __future__ import annotations
import asyncio
from ont_platform.core.review import CandidatePromotionService
from ont_platform.core.validation import OntologyGuard
from ont_platform.storage.candidate_repository import CandidateRepository
from ont_platform.storage.models import Base, ReviewStatus
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
def _session() -> Session:
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
return sessionmaker(bind=engine)()
def test_guardrails_facade_reports_structured_schema_issues() -> None:
async def run():
guard = OntologyGuard(validator_type="guardrails", strict=False)
return await guard.validate(
{
"entities": [
{
"id": "E_bad",
"label": "Bad Confidence",
"type": "concept",
"confidence": 1.5,
}
],
"relations": [],
}
)
result = asyncio.run(run())
assert result.validation_passed is False
assert result.validation_issues
assert result.validation_issues[0].source == "guardrails_facade"
assert result.validation_issues[0].code == "entity_schema_violation"
def test_validation_issues_are_stored_and_block_promotion() -> None:
db = _session()
repository = CandidateRepository(db)
batch = repository.save_ontocast_result(
project_id="proj_guard",
document_id="doc_guard",
result={
"entities": [
{
"id": "E_guard",
"label": "Guarded",
"entity_type": "concept",
"confidence": 0.91,
"evidence_ids": ["EV_guard"],
}
],
"relations": [],
"evidence_spans": [
{
"id": "EV_guard",
"text": "Guarded output has evidence.",
"start_offset": 0,
"end_offset": 28,
}
],
"validation_issues": [
{
"severity": "error",
"code": "confidence_range",
"message": "confidence must be between 0 and 1",
}
],
},
validation_passed=False,
)
entity = batch.entities[0]
entity.review_status = ReviewStatus.APPROVED
issues = repository.list_validation_issues(project_id="proj_guard")
plan = CandidatePromotionService(repository).build_commit_plan(project_id="proj_guard")
assert len(issues) == 1
assert issues[0].code == "confidence_range"
assert len(plan.entities) == 0
assert plan.blocked[0]["reason"] == "validation_failed"

View File

@@ -0,0 +1,105 @@
"""Phase 5 projection and GraphRAG boundary tests."""
from __future__ import annotations
import asyncio
from datetime import datetime
import pytest
from ont_platform.core.graph.cypher_guard import ReadOnlyCypherGuard, UnsafeCypherError
from ont_platform.core.graph.search import CandidateGraphSearchService
from ont_platform.core.projection.rdf_to_neo4j import RDFToNeo4jProjector
from ont_platform.storage.candidate_repository import CandidateRepository
from ont_platform.storage.models import Base, SourceDocument
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
def _session() -> Session:
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
return sessionmaker(bind=engine)()
def test_rdf_projection_keeps_neo4j_as_projection_store() -> None:
async def run():
projector = RDFToNeo4jProjector(project_id="proj_graph")
return await projector.preview_projection(
[
("http://example.test/Alice", "http://example.test/knows", "http://example.test/Bob"),
("http://example.test/Alice", "http://www.w3.org/2000/01/rdf-schema#label", "Alice"),
],
provenance={"source_url": "https://example.test/source", "evidence_ids": ["EV_graph"]},
)
result = asyncio.run(run())
payload = result.to_dict()
assert payload["contract"]["canonical_store"] == "rdf_fuseki"
assert payload["contract"]["projection_store"] == "neo4j"
assert payload["node_count"] >= 2
assert payload["relationships"][0]["provenance"]["evidence_ids"] == ["EV_graph"]
assert payload["source_graph_hash"]
def test_read_only_cypher_guard_blocks_writes_and_enforces_limit() -> None:
guard = ReadOnlyCypherGuard(max_limit=25)
sanitized = guard.sanitize("MATCH (n:Entity) RETURN n", limit=100)
assert "LIMIT 25" in sanitized.query
with pytest.raises(UnsafeCypherError):
guard.sanitize("MATCH (n) DETACH DELETE n")
def test_candidate_graph_search_returns_source_provenance() -> None:
db = _session()
document = SourceDocument(
id="doc_graph",
project_id="proj_graph",
source_url="https://example.test/source",
document_type="html",
title="Graph Source",
text="Alice works at Acme.",
content_hash="hash_graph",
fingerprint="fp_graph",
retrieved_at=datetime.utcnow(),
extracted_by="trafilatura",
)
db.add(document)
repository = CandidateRepository(db)
repository.save_lightweight_result(
project_id="proj_graph",
document_id="doc_graph",
result={
"entities": [
{
"id": "E_alice_graph",
"label": "Alice",
"type": "person",
"confidence": 0.9,
"evidence_ids": ["EV_graph"],
}
],
"relations": [],
"evidence_spans": [
{
"id": "EV_graph",
"text": "Alice works at Acme.",
"start_offset": 0,
"end_offset": 20,
}
],
},
)
results = CandidateGraphSearchService(db).search(
project_id="proj_graph",
query="alice",
limit=10,
)
assert len(results) == 1
payload = results[0].to_dict()
assert payload["provenance"]["source_url"] == "https://example.test/source"
assert payload["provenance"]["evidence_spans"][0]["id"] == "EV_graph"

View File

@@ -0,0 +1,138 @@
"""Phase 6 maintenance loop tests."""
from __future__ import annotations
import asyncio
from datetime import datetime
import pytest
from ont_platform.core.maintenance import MaintenanceLoopService, MaintenancePermissionError
from ont_platform.storage.candidate_repository import CandidateRepository
from ont_platform.storage.models import (
Base,
CandidateEntity,
MaintenanceProposalStatus,
ReviewStatus,
SourceDocument,
)
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
def _session() -> Session:
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
return sessionmaker(bind=engine)()
def _seed_project(db: Session) -> None:
db.add(
SourceDocument(
id="doc_phase6",
project_id="proj_phase6",
source_url="https://example.test/phase6",
document_type="html",
title="Phase 6 Source",
text="Alice works at Acme.",
content_hash="hash_phase6",
fingerprint="fp_phase6",
retrieved_at=datetime.utcnow(),
extracted_by="trafilatura",
)
)
repository = CandidateRepository(db)
repository.save_lightweight_result(
project_id="proj_phase6",
document_id="doc_phase6",
result={
"entities": [
{
"id": "E_alice_a",
"label": "Alice",
"type": "person",
"confidence": 0.9,
"evidence_ids": ["EV_phase6"],
},
{
"id": "E_alice_b",
"label": "Alice",
"type": "person",
"confidence": 0.5,
"evidence_ids": [],
},
],
"relations": [],
"evidence_spans": [
{
"id": "EV_phase6",
"text": "Alice works at Acme.",
"start_offset": 0,
"end_offset": 20,
}
],
"validation_issues": [
{
"severity": "error",
"code": "relation_endpoint_missing",
"message": "Relation target is missing",
"candidate_id": "R_missing",
"candidate_kind": "relation",
}
],
},
validation_passed=True,
)
def test_maintenance_loop_creates_pending_proposals_without_mutating_candidates() -> None:
async def run():
db = _session()
_seed_project(db)
service = MaintenanceLoopService(db)
run_model = await service.run(
project_id="proj_phase6",
requested_by="ops",
actor_role="admin",
)
proposals = service.list_proposals(project_id="proj_phase6")
candidate = db.get(CandidateEntity, "E_alice_b")
return run_model, proposals, candidate
run_model, proposals, candidate = asyncio.run(run())
assert run_model.status.value == "completed"
assert run_model.summary["direct_mutations"] == 0
assert run_model.summary["approval_gate"] == "required"
assert run_model.budget_summary["usage"]["operation_type"] == "analysis"
assert run_model.audit_summary["action"] == "ANALYZE"
assert proposals
assert {proposal.status for proposal in proposals} == {MaintenanceProposalStatus.PENDING_REVIEW}
assert candidate.review_status == ReviewStatus.PENDING
def test_maintenance_proposal_requires_admin_approval_permission() -> None:
async def run():
db = _session()
_seed_project(db)
service = MaintenanceLoopService(db)
await service.run(project_id="proj_phase6", requested_by="ops", actor_role="admin")
proposal = service.list_proposals(project_id="proj_phase6")[0]
with pytest.raises(MaintenancePermissionError):
await service.review_proposal(
proposal_id=proposal.id,
reviewed_by="viewer",
actor_role="viewer",
approve=True,
)
approved = await service.review_proposal(
proposal_id=proposal.id,
reviewed_by="admin",
actor_role="admin",
approve=True,
)
return approved
approved = asyncio.run(run())
assert approved.status == MaintenanceProposalStatus.APPROVED
assert approved.approved_by == "admin"

View File

@@ -0,0 +1,147 @@
from __future__ import annotations
import pytest
from ont_platform.core.review import (
CandidatePromotionService,
EvidenceRequiredError,
InvalidReviewTransitionError,
ReviewService,
)
from ont_platform.storage.candidate_repository import CandidateRepository
from ont_platform.storage.models import Base, CandidateEntity, CandidateKind, ReviewStatus
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
def _session() -> Session:
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
return sessionmaker(bind=engine)()
def _seed_candidate(
repository: CandidateRepository,
*,
candidate_id: str = "E_review",
evidence_id: str = "EV_review",
with_evidence: bool = True,
) -> CandidateEntity:
payload = {
"entities": [
{
"id": candidate_id,
"label": "Review",
"type": "concept",
"confidence": 0.92,
"source_trust": 0.9,
"validation_passed": True,
"evidence_ids": [evidence_id] if with_evidence else [],
}
],
"relations": [],
"evidence_spans": [
{
"id": evidence_id,
"text": "Review candidates need evidence.",
"start_offset": 0,
"end_offset": 32,
}
]
if with_evidence
else [],
}
return repository.save_lightweight_result(
project_id="proj_1",
document_id="doc_1",
result=payload,
source_trust=0.9,
validation_passed=True,
).entities[0]
def test_approve_requires_valid_evidence_and_records_decision() -> None:
db = _session()
repository = CandidateRepository(db)
entity = _seed_candidate(repository)
decision = ReviewService(repository).approve(
candidate_kind=CandidateKind.ENTITY,
candidate_id=entity.id,
reviewed_by="lasta",
reason="looks good",
)
assert entity.review_status == ReviewStatus.APPROVED
assert decision.previous_status == ReviewStatus.PENDING
assert decision.new_status == ReviewStatus.APPROVED
assert len(repository.review_history(candidate_kind=CandidateKind.ENTITY, candidate_id=entity.id)) == 1
def test_candidate_without_evidence_cannot_be_approved() -> None:
db = _session()
repository = CandidateRepository(db)
entity = _seed_candidate(repository, with_evidence=False)
with pytest.raises(EvidenceRequiredError):
ReviewService(repository).approve(
candidate_kind=CandidateKind.ENTITY,
candidate_id=entity.id,
reviewed_by="lasta",
)
assert entity.review_status == ReviewStatus.PENDING
def test_auto_approve_requires_policy_thresholds() -> None:
db = _session()
repository = CandidateRepository(db)
entity = _seed_candidate(repository)
decision = ReviewService(repository).auto_approve(
candidate_kind=CandidateKind.ENTITY,
candidate_id=entity.id,
)
assert decision.new_status == ReviewStatus.AUTO_APPROVED
assert entity.review_status == ReviewStatus.AUTO_APPROVED
def test_rejected_candidate_cannot_be_approved_again() -> None:
db = _session()
repository = CandidateRepository(db)
entity = _seed_candidate(repository)
service = ReviewService(repository)
service.reject(
candidate_kind=CandidateKind.ENTITY,
candidate_id=entity.id,
reviewed_by="lasta",
)
with pytest.raises(InvalidReviewTransitionError):
service.approve(
candidate_kind=CandidateKind.ENTITY,
candidate_id=entity.id,
reviewed_by="lasta",
)
def test_promotion_plan_blocks_approved_candidate_without_evidence() -> None:
db = _session()
repository = CandidateRepository(db)
valid = _seed_candidate(repository)
invalid = _seed_candidate(repository, candidate_id="E_no_evidence", with_evidence=False)
invalid.review_status = ReviewStatus.APPROVED
valid.review_status = ReviewStatus.APPROVED
plan = CandidatePromotionService(repository).build_commit_plan(project_id="proj_1")
assert [entity.id for entity in plan.entities] == ["E_review"]
assert plan.blocked == [
{
"candidate_kind": "entity",
"candidate_id": "E_no_evidence",
"reason": "missing_or_invalid_evidence",
"review_status": "approved",
}
]

View File

@@ -0,0 +1,64 @@
from __future__ import annotations
from pathlib import Path
from ont_platform.core.extraction.schemas import SourceDocumentSchema
from ont_platform.core.extractors.web_extractor import WebExtractor, extract_web_content
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "korean"
def test_extract_from_korean_html_preserves_document_contract() -> None:
html = (FIXTURES / "news_yonhap.html").read_text(encoding="utf-8")
extracted = WebExtractor().extract_from_html(
html,
source_url="https://example.test/news/data-quality?utm=tracking",
)
assert "공공 데이터 품질 관리 체계" in extracted.text
assert extracted.title == "정부, 공공 데이터 품질 관리 체계 확대"
assert extracted.language == "ko"
assert extracted.canonical_url == "https://example.test/news/data-quality"
assert extracted.content_hash
assert extracted.fingerprint.startswith("sha1:")
assert extracted.body_xml
assert extracted.metadata["source"] == "trafilatura"
def test_same_clean_body_gets_same_hash_and_fingerprint() -> None:
body = """
<article>
<h1>중복 문서</h1>
<p>Alice works at Acme in Berlin. The ontology platform keeps evidence spans.</p>
<p>Alice works at Acme in Berlin. The ontology platform keeps evidence spans.</p>
<p>Alice works at Acme in Berlin. The ontology platform keeps evidence spans.</p>
</article>
"""
first_html = f"<html><head><title>중복 문서</title></head><body>{body}</body></html>"
second_html = f"<html><head><title>중복 문서</title></head><body><nav>menu</nav>{body}</body></html>"
first = extract_web_content(html=first_html, url="https://example.test/a")
second = extract_web_content(html=second_html, url="https://example.test/b")
assert first.content_hash == second.content_hash
assert first.fingerprint == second.fingerprint
assert first.document_id == second.document_id
def test_extracted_content_maps_to_source_document_and_evidence_spans() -> None:
html = (FIXTURES / "blog_naver.html").read_text(encoding="utf-8")
extracted = extract_web_content(html=html, url="https://example.test/blog/ontology-build-log")
source_document = extracted.to_source_document(project_id="proj_1")
spans = extracted.evidence_spans(project_id="proj_1", document_id=source_document.id)
assert source_document.project_id == "proj_1"
assert source_document.source_url == "https://example.test/blog/ontology-build-log"
assert source_document.content_hash == extracted.content_hash
assert source_document.metadata_["source"] == "trafilatura"
schema = SourceDocumentSchema.model_validate(source_document)
assert schema.metadata["source"] == "trafilatura"
assert spans
assert spans[0].start_offset >= 0
assert spans[0].end_offset <= len(extracted.text)