docs
This commit is contained in:
@@ -7,6 +7,8 @@ Ensures proper sys.path setup so that:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -21,3 +23,26 @@ for p in (REPO_ROOT, VENDORED_ONTOCAST):
|
||||
# Remove stdlib 'platform' to avoid conflict with our platform package
|
||||
if "platform" in sys.modules:
|
||||
del sys.modules["platform"]
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
config.addinivalue_line("markers", "asyncio: run async test functions")
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
parser.addini("asyncio_mode", "asyncio compatibility mode", default="auto")
|
||||
|
||||
|
||||
def pytest_pyfunc_call(pyfuncitem):
|
||||
"""Minimal asyncio runner for environments without pytest-asyncio."""
|
||||
|
||||
if not inspect.iscoroutinefunction(pyfuncitem.obj):
|
||||
return None
|
||||
|
||||
kwargs = {
|
||||
name: pyfuncitem.funcargs[name]
|
||||
for name in pyfuncitem._fixtureinfo.argnames
|
||||
if name in pyfuncitem.funcargs
|
||||
}
|
||||
asyncio.run(pyfuncitem.obj(**kwargs))
|
||||
return True
|
||||
|
||||
21
ontology_platform/tests/fixtures/korean/blog_naver.html
vendored
Normal file
21
ontology_platform/tests/fixtures/korean/blog_naver.html
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>온톨로지 구축 실험 기록</title>
|
||||
<meta name="author" content="박지훈">
|
||||
<meta name="description" content="작은 팀이 온톨로지 구축 과정을 점검한 기록">
|
||||
<meta property="og:site_name" content="기술 블로그">
|
||||
<link rel="canonical" href="https://example.test/blog/ontology-build-log">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<article>
|
||||
<h1>온톨로지 구축 실험 기록</h1>
|
||||
<p>지난주 팀은 여러 웹 페이지를 수집한 뒤 본문만 남기는 실험을 진행했다. HTML 안에는 메뉴, 광고, 댓글, 추천 글이 함께 있었지만 실제 분석에 필요한 부분은 제목과 본문, 작성자, 게시 시각이었다.</p>
|
||||
<p>가장 중요한 교훈은 추출 결과를 바로 그래프에 넣지 않는다는 점이었다. 먼저 SourceDocument로 정리하고 EvidenceSpan으로 근거를 나누면, 이후 사람이 후보 엔티티와 관계를 검토할 때 훨씬 쉽게 판단할 수 있었다.</p>
|
||||
<p>두 번째 실험에서는 같은 글을 다른 URL로 저장해 중복 수집을 확인했다. content hash와 fingerprint가 같으면 이미 처리한 문서로 판단하고, 비용이 큰 LLM 호출이나 후속 변환을 생략할 수 있었다.</p>
|
||||
</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
20
ontology_platform/tests/fixtures/korean/news_yonhap.html
vendored
Normal file
20
ontology_platform/tests/fixtures/korean/news_yonhap.html
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>정부, 공공 데이터 품질 관리 체계 확대</title>
|
||||
<meta name="author" content="김민서">
|
||||
<meta name="description" content="공공 데이터 품질 관리와 근거 문장 추적 체계 확대 소식">
|
||||
<meta property="og:site_name" content="연합뉴스">
|
||||
<meta property="article:published_time" content="2026-05-18">
|
||||
<link rel="canonical" href="https://example.test/news/data-quality">
|
||||
</head>
|
||||
<body>
|
||||
<article>
|
||||
<h1>정부, 공공 데이터 품질 관리 체계 확대</h1>
|
||||
<p>정부는 서울에서 열린 디지털 행정 회의에서 공공 데이터 품질 관리 체계를 확대하겠다고 밝혔다. 새 체계는 원문 수집, 본문 정제, 근거 문장 추적, 후보 검토, 최종 승인 절차를 하나의 흐름으로 연결한다.</p>
|
||||
<p>관계자는 데이터가 자동으로 추출되더라도 출처와 생성 방식이 함께 남아야 한다고 설명했다. 특히 시민에게 공개되는 지식 그래프에는 신뢰도, 검증 결과, 담당 부서가 함께 기록되어야 한다고 강조했다.</p>
|
||||
<p>이번 계획에는 지방자치단체가 보유한 문서와 웹 페이지를 표준 문서 단위로 변환하는 작업도 포함됐다. 플랫폼은 같은 본문이 다른 주소에서 반복 수집될 경우 fingerprint를 비교해 중복 처리를 줄인다.</p>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
||||
19
ontology_platform/tests/fixtures/korean/shop_coupang.html
vendored
Normal file
19
ontology_platform/tests/fixtures/korean/shop_coupang.html
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>프리미엄 데이터 품질 점검 도구</title>
|
||||
<meta name="author" content="상품기획팀">
|
||||
<meta name="description" content="데이터 품질 점검 도구의 상품 상세 페이지">
|
||||
<meta property="og:site_name" content="샘플 쇼핑">
|
||||
<link rel="canonical" href="https://example.test/shop/data-quality-tool">
|
||||
</head>
|
||||
<body>
|
||||
<section class="product-detail">
|
||||
<h1>프리미엄 데이터 품질 점검 도구</h1>
|
||||
<p>이 도구는 수집된 웹 문서의 제목, 본문, 언어, 출처 URL을 한 화면에서 확인하도록 설계되었다. 운영자는 문서별 content hash와 fingerprint를 비교해 같은 상품 설명이 여러 경로로 들어왔는지 빠르게 판단할 수 있다.</p>
|
||||
<p>상품 설명에는 자동 추출된 핵심 개념, 검토 대기 중인 후보, 사람이 승인한 관계, 반려된 항목이 함께 표시된다. 데이터 팀은 이 정보를 기반으로 지식 그래프에 반영할 항목과 보류할 항목을 나눈다.</p>
|
||||
<p>구매 고객은 API 응답에서 SourceDocument와 EvidenceSpan이 분리되어 제공되는 점을 높게 평가했다. 근거 문장이 함께 전달되면 분석 결과를 다시 검토하거나 외부 감사에 대응하기 쉽기 때문이다.</p>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
@@ -137,6 +137,14 @@ def test_info_shape() -> None:
|
||||
assert "text-to-triples" in body["capabilities"]
|
||||
|
||||
|
||||
def test_phase0_does_not_mount_future_extraction_route() -> None:
|
||||
"""Phase 0 app startup must not depend on Phase 1 extraction packages."""
|
||||
ctx = _make_mock_context([])
|
||||
with _client_with_context(ctx) as client:
|
||||
response = client.post("/api/v1/extract/url", params={"url": "https://example.com"})
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# ─── /flush ───────────────────────────────────────────────────────────────
|
||||
def test_flush_requires_confirmation_token() -> None:
|
||||
ctx = _make_mock_context([])
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Phase 3 crawl job API tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from collections.abc import Generator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from ont_platform.api import db_deps
|
||||
from ont_platform.storage.models import Base
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
main_module = importlib.import_module("ont_platform.api.main")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _noop_lifespan(app):
|
||||
yield
|
||||
|
||||
|
||||
def _phase3_client(monkeypatch) -> TestClient:
|
||||
monkeypatch.setenv("PHASE", "3")
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
session_factory = sessionmaker(bind=engine)
|
||||
|
||||
def override_get_db() -> Generator[Session, None, None]:
|
||||
db = session_factory()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app = main_module.create_app()
|
||||
app.router.lifespan_context = _noop_lifespan
|
||||
app.dependency_overrides[db_deps.get_db] = override_get_db
|
||||
return TestClient(app, raise_server_exceptions=True, backend="asyncio")
|
||||
|
||||
|
||||
def test_phase3_crawl_job_accepts_inline_html_and_persists_progress(monkeypatch) -> None:
|
||||
html = """
|
||||
<html lang="en">
|
||||
<head><title>Ontology Job</title></head>
|
||||
<body><article><h1>Ontology Job</h1><p>Alice works at Acme in Berlin.</p></article></body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
with _phase3_client(monkeypatch) as client:
|
||||
response = client.post(
|
||||
"/api/v1/crawl/jobs",
|
||||
json={
|
||||
"project_id": "proj_crawl",
|
||||
"url": "https://example.test/job",
|
||||
"html": html,
|
||||
"profile": "dynamic_page",
|
||||
},
|
||||
)
|
||||
body = response.json()
|
||||
job_id = body["job"]["id"]
|
||||
status = client.get(f"/api/v1/crawl/jobs/{job_id}")
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert body["job"]["status"] == "completed"
|
||||
assert body["job"]["document_id"].startswith("doc_")
|
||||
assert body["job"]["entity_count"] >= 1
|
||||
assert body["job"]["metadata"]["progress"]["profile"] == "dynamic_page"
|
||||
assert body["job"]["metadata"]["progress"]["pages_completed"] == 1
|
||||
assert status.status_code == 200
|
||||
assert status.json()["job"]["id"] == job_id
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Phase 6 maintenance API tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from collections.abc import Generator
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from ont_platform.api import db_deps
|
||||
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
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
main_module = importlib.import_module("ont_platform.api.main")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _noop_lifespan(app):
|
||||
yield
|
||||
|
||||
|
||||
def _phase6_client(monkeypatch) -> TestClient:
|
||||
monkeypatch.setenv("PHASE", "6")
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
session_factory = sessionmaker(bind=engine)
|
||||
|
||||
def override_get_db() -> Generator[Session, None, None]:
|
||||
db = session_factory()
|
||||
if db.get(SourceDocument, "doc_api_phase6") is None:
|
||||
_seed(db)
|
||||
db.commit()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app = main_module.create_app()
|
||||
app.router.lifespan_context = _noop_lifespan
|
||||
app.dependency_overrides[db_deps.get_db] = override_get_db
|
||||
return TestClient(app, raise_server_exceptions=True, backend="asyncio")
|
||||
|
||||
|
||||
def _seed(db: Session) -> None:
|
||||
db.add(
|
||||
SourceDocument(
|
||||
id="doc_api_phase6",
|
||||
project_id="proj_api_phase6",
|
||||
source_url="https://example.test/api-phase6",
|
||||
document_type="html",
|
||||
title="API Phase 6 Source",
|
||||
text="Acme appears in a source.",
|
||||
content_hash="hash_api_phase6",
|
||||
fingerprint="fp_api_phase6",
|
||||
retrieved_at=datetime.utcnow(),
|
||||
extracted_by="trafilatura",
|
||||
)
|
||||
)
|
||||
CandidateRepository(db).save_lightweight_result(
|
||||
project_id="proj_api_phase6",
|
||||
document_id="doc_api_phase6",
|
||||
result={
|
||||
"entities": [
|
||||
{
|
||||
"id": "E_api_phase6",
|
||||
"label": "Acme",
|
||||
"type": "org",
|
||||
"confidence": 0.4,
|
||||
"evidence_ids": [],
|
||||
}
|
||||
],
|
||||
"relations": [],
|
||||
"evidence_spans": [],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_phase6_api_runs_loop_and_reviews_proposal(monkeypatch) -> None:
|
||||
with _phase6_client(monkeypatch) as client:
|
||||
run_response = client.post(
|
||||
"/api/v1/maintenance/runs",
|
||||
json={
|
||||
"project_id": "proj_api_phase6",
|
||||
"requested_by": "ops",
|
||||
"actor_role": "admin",
|
||||
},
|
||||
)
|
||||
proposal_response = client.get(
|
||||
"/api/v1/maintenance/proposals",
|
||||
params={"project_id": "proj_api_phase6", "status": "pending_review"},
|
||||
)
|
||||
proposal_id = proposal_response.json()["proposals"][0]["id"]
|
||||
review_response = client.post(
|
||||
f"/api/v1/maintenance/proposals/{proposal_id}/review",
|
||||
json={"reviewed_by": "admin", "actor_role": "admin", "approve": True},
|
||||
)
|
||||
|
||||
assert run_response.status_code == 200, run_response.text
|
||||
assert run_response.json()["run"]["summary"]["direct_mutations"] == 0
|
||||
assert proposal_response.status_code == 200
|
||||
assert proposal_response.json()["proposals"]
|
||||
assert review_response.status_code == 200, review_response.text
|
||||
assert review_response.json()["proposal"]["status"] == "approved"
|
||||
123
ontology_platform/tests/integration/test_review_api.py
Normal file
123
ontology_platform/tests/integration/test_review_api.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""Phase 2 review queue API tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from collections.abc import Generator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from ont_platform.api import db_deps
|
||||
from ont_platform.storage.models import Base
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
main_module = importlib.import_module("ont_platform.api.main")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _noop_lifespan(app):
|
||||
yield
|
||||
|
||||
|
||||
def _phase2_client(monkeypatch) -> TestClient:
|
||||
monkeypatch.setenv("PHASE", "2")
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
session_factory = sessionmaker(bind=engine)
|
||||
|
||||
def override_get_db() -> Generator[Session, None, None]:
|
||||
db = session_factory()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app = main_module.create_app()
|
||||
app.router.lifespan_context = _noop_lifespan
|
||||
app.dependency_overrides[db_deps.get_db] = override_get_db
|
||||
return TestClient(app, raise_server_exceptions=True, backend="asyncio")
|
||||
|
||||
|
||||
def test_review_api_ingests_approves_and_promotes_candidate(monkeypatch) -> None:
|
||||
with _phase2_client(monkeypatch) as client:
|
||||
ingest = client.post(
|
||||
"/api/v1/review/ingest/lightweight",
|
||||
json={
|
||||
"project_id": "proj_api",
|
||||
"document_id": "doc_api",
|
||||
"entities": [
|
||||
{
|
||||
"id": "E_api",
|
||||
"label": "Acme",
|
||||
"type": "org",
|
||||
"confidence": 0.93,
|
||||
"evidence_ids": ["EV_api"],
|
||||
}
|
||||
],
|
||||
"relations": [],
|
||||
"evidence_spans": [
|
||||
{
|
||||
"id": "EV_api",
|
||||
"text": "Acme is mentioned in the source.",
|
||||
"start_offset": 0,
|
||||
"end_offset": 32,
|
||||
}
|
||||
],
|
||||
"source_trust": 0.9,
|
||||
"validation_passed": True,
|
||||
},
|
||||
)
|
||||
approve = client.post(
|
||||
"/api/v1/review/candidates/entity/E_api/approve",
|
||||
json={"reviewed_by": "lasta", "reason": "verified"},
|
||||
)
|
||||
promote = client.post("/api/v1/review/promote", params={"project_id": "proj_api"})
|
||||
|
||||
assert ingest.status_code == 200, ingest.text
|
||||
assert ingest.json()["source_type"] == "lightweight"
|
||||
assert approve.status_code == 200, approve.text
|
||||
assert approve.json()["decision"]["new_status"] == "approved"
|
||||
assert promote.status_code == 200, promote.text
|
||||
plan = promote.json()["promotion_plan"]
|
||||
assert plan["entity_count"] == 1
|
||||
assert plan["blocked_count"] == 0
|
||||
assert plan["entities"][0]["id"] == "E_api"
|
||||
|
||||
|
||||
def test_review_api_blocks_approval_without_evidence(monkeypatch) -> None:
|
||||
with _phase2_client(monkeypatch) as client:
|
||||
ingest = client.post(
|
||||
"/api/v1/review/ingest/ontocast",
|
||||
json={
|
||||
"project_id": "proj_api",
|
||||
"document_id": "doc_api",
|
||||
"entities": [
|
||||
{
|
||||
"id": "E_no_evidence_api",
|
||||
"label": "Unsupported",
|
||||
"entity_type": "concept",
|
||||
"confidence": 0.99,
|
||||
"evidence_ids": [],
|
||||
}
|
||||
],
|
||||
"relations": [],
|
||||
"evidence_spans": [],
|
||||
"source_trust": 0.95,
|
||||
"validation_passed": True,
|
||||
},
|
||||
)
|
||||
approve = client.post(
|
||||
"/api/v1/review/candidates/entity/E_no_evidence_api/approve",
|
||||
json={"reviewed_by": "lasta"},
|
||||
)
|
||||
|
||||
assert ingest.status_code == 200, ingest.text
|
||||
assert ingest.json()["source_type"] == "ontocast"
|
||||
assert approve.status_code == 422
|
||||
assert "without valid evidence" in approve.json()["detail"]
|
||||
87
ontology_platform/tests/integration/test_url_ingest.py
Normal file
87
ontology_platform/tests/integration/test_url_ingest.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Phase 1 URL/HTML ingestion API tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from ont_platform.storage.dedup_cache import reset_default_dedup_cache
|
||||
|
||||
main_module = importlib.import_module("ont_platform.api.main")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _noop_lifespan(app):
|
||||
yield
|
||||
|
||||
|
||||
def _phase1_client(monkeypatch) -> TestClient:
|
||||
monkeypatch.setenv("PHASE", "1")
|
||||
reset_default_dedup_cache()
|
||||
app = main_module.create_app()
|
||||
app.router.lifespan_context = _noop_lifespan
|
||||
return TestClient(app, raise_server_exceptions=True, backend="asyncio")
|
||||
|
||||
|
||||
def test_extract_url_accepts_html_payload_and_returns_source_document(
|
||||
monkeypatch,
|
||||
fixtures_dir: Path,
|
||||
) -> None:
|
||||
html = (fixtures_dir / "korean" / "news_yonhap.html").read_text(encoding="utf-8")
|
||||
|
||||
with _phase1_client(monkeypatch) as client:
|
||||
response = client.post(
|
||||
"/api/v1/extract/url",
|
||||
json={
|
||||
"url": "https://example.test/news/data-quality",
|
||||
"html": html,
|
||||
"project_id": "proj_phase1",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["status"] == "success"
|
||||
assert body["source_document"]["source_url"] == "https://example.test/news/data-quality"
|
||||
assert body["source_document"]["language"] == "ko"
|
||||
assert body["source_document"]["content_hash"]
|
||||
assert body["evidence_spans"]
|
||||
assert body["content_unit"]["doc_iri"].startswith("urn:source:doc_")
|
||||
assert body["dedup"]["is_duplicate"] is False
|
||||
|
||||
|
||||
def test_process_url_skips_duplicate_payload_by_fingerprint(
|
||||
monkeypatch,
|
||||
fixtures_dir: Path,
|
||||
) -> None:
|
||||
html = (fixtures_dir / "korean" / "blog_naver.html").read_text(encoding="utf-8")
|
||||
|
||||
with _phase1_client(monkeypatch) as client:
|
||||
first = client.post(
|
||||
"/process/url",
|
||||
json={
|
||||
"url": "https://example.test/blog/original",
|
||||
"html": html,
|
||||
"project_id": "proj_phase1",
|
||||
},
|
||||
)
|
||||
second = client.post(
|
||||
"/process/url",
|
||||
json={
|
||||
"url": "https://example.test/blog/mirror",
|
||||
"html": html,
|
||||
"project_id": "proj_phase1",
|
||||
},
|
||||
)
|
||||
|
||||
assert first.status_code == 200, first.text
|
||||
assert second.status_code == 200, second.text
|
||||
first_body = first.json()
|
||||
second_body = second.json()
|
||||
assert first_body["dedup"]["is_duplicate"] is False
|
||||
assert second_body["dedup"]["is_duplicate"] is True
|
||||
assert second_body["dedup"]["existing_document_id"] == first_body["source_document"]["id"]
|
||||
assert second_body["entity_count"] == 0
|
||||
assert "Duplicate source document skipped" in second_body["warnings"][0]
|
||||
91
ontology_platform/tests/unit/test_candidate_repository.py
Normal file
91
ontology_platform/tests/unit/test_candidate_repository.py
Normal 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"
|
||||
21
ontology_platform/tests/unit/test_content_unit.py
Normal file
21
ontology_platform/tests/unit/test_content_unit.py
Normal 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_")
|
||||
32
ontology_platform/tests/unit/test_dedup_cache.py
Normal file
32
ontology_platform/tests/unit/test_dedup_cache.py
Normal 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
|
||||
@@ -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"
|
||||
105
ontology_platform/tests/unit/test_phase5_projection_graphrag.py
Normal file
105
ontology_platform/tests/unit/test_phase5_projection_graphrag.py
Normal 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"
|
||||
138
ontology_platform/tests/unit/test_phase6_maintenance_loop.py
Normal file
138
ontology_platform/tests/unit/test_phase6_maintenance_loop.py
Normal 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"
|
||||
147
ontology_platform/tests/unit/test_review_service.py
Normal file
147
ontology_platform/tests/unit/test_review_service.py
Normal 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",
|
||||
}
|
||||
]
|
||||
64
ontology_platform/tests/unit/test_web_extractor.py
Normal file
64
ontology_platform/tests/unit/test_web_extractor.py
Normal 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)
|
||||
Reference in New Issue
Block a user