ontology
This commit is contained in:
0
ontology_platform/tests/__init__.py
Normal file
0
ontology_platform/tests/__init__.py
Normal file
23
ontology_platform/tests/conftest.py
Normal file
23
ontology_platform/tests/conftest.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Pytest configuration for all tests.
|
||||
|
||||
Ensures proper sys.path setup so that:
|
||||
1. `platform.*` modules are imported from ./platform (not stdlib)
|
||||
2. `ontocast.*` modules are imported from ./vendored/ontocast
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
VENDORED_ONTOCAST = REPO_ROOT / "vendored" / "ontocast"
|
||||
|
||||
for p in (REPO_ROOT, VENDORED_ONTOCAST):
|
||||
p_str = str(p)
|
||||
if p_str not in sys.path:
|
||||
sys.path.insert(0, p_str)
|
||||
|
||||
# Remove stdlib 'platform' to avoid conflict with our platform package
|
||||
if "platform" in sys.modules:
|
||||
del sys.modules["platform"]
|
||||
0
ontology_platform/tests/e2e/__init__.py
Normal file
0
ontology_platform/tests/e2e/__init__.py
Normal file
45
ontology_platform/tests/e2e/conftest.py
Normal file
45
ontology_platform/tests/e2e/conftest.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""E2E test configuration.
|
||||
|
||||
These tests are SKIPPED by default. To run them set ``LLM_API_KEY`` and
|
||||
``LLM_PROVIDER`` (and any model overrides) in the environment, then run:
|
||||
|
||||
pytest tests/e2e -m e2e
|
||||
|
||||
They exercise the full OntoCast workflow end-to-end — LLM calls included —
|
||||
and so they cost real money. Keep them out of CI default runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
for p in (REPO_ROOT, REPO_ROOT / "vendored" / "ontocast"):
|
||||
if str(p) not in sys.path:
|
||||
sys.path.insert(0, str(p))
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(
|
||||
config: pytest.Config, items: list[pytest.Item]
|
||||
) -> None:
|
||||
"""Skip all e2e tests when LLM_API_KEY is not configured."""
|
||||
if os.environ.get("LLM_API_KEY"):
|
||||
return
|
||||
skip_marker = pytest.mark.skip(
|
||||
reason="E2E tests require LLM_API_KEY in environment."
|
||||
)
|
||||
for item in items:
|
||||
# Only apply to tests in this directory tree.
|
||||
if "tests/e2e" in str(item.fspath).replace("\\", "/"):
|
||||
item.add_marker(skip_marker)
|
||||
|
||||
|
||||
def pytest_configure(config: pytest.Config) -> None:
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"e2e: end-to-end test that requires a real LLM and may cost money",
|
||||
)
|
||||
93
ontology_platform/tests/e2e/test_phase0_full_pipeline.py
Normal file
93
ontology_platform/tests/e2e/test_phase0_full_pipeline.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Phase 0 end-to-end pipeline test.
|
||||
|
||||
Runs the actual OntoCast workflow through the FastAPI ``/process`` endpoint
|
||||
with a tiny JSON input. Verifies Acceptance Gate 0 evidence:
|
||||
|
||||
- /health, /info, /process respond OK
|
||||
- ontology + facts Turtle are produced
|
||||
- BudgetTracker reports non-zero LLM call/triple counts
|
||||
- Filesystem TripleStoreManager writes artifacts under working_directory
|
||||
|
||||
Skipped unless ``LLM_API_KEY`` is set (see ``conftest.py``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
main_module = importlib.import_module("platform.api.main")
|
||||
deps_module = importlib.import_module("platform.api.deps")
|
||||
platform_config = importlib.import_module("platform.config")
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_pipeline_writes_ontology_and_facts(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# Point the working directory at the test's tmp_path so artifacts don't
|
||||
# leak between runs.
|
||||
monkeypatch.setenv("ONTOCAST_WORKING_DIRECTORY", str(tmp_path / "work"))
|
||||
# Honor whatever LLM provider the operator configured.
|
||||
assert os.environ.get("LLM_API_KEY"), "LLM_API_KEY must be set for e2e"
|
||||
|
||||
# Force a fresh AppContext so the new working_directory wins.
|
||||
deps_module.reset_app_context_for_testing()
|
||||
settings = platform_config.load_settings()
|
||||
await deps_module.initialize_app_context(settings, head_chunks=1)
|
||||
|
||||
app = main_module.create_app()
|
||||
|
||||
# Tiny but ontology-rich payload.
|
||||
payload = {
|
||||
"text": (
|
||||
"Alice works at Acme Corporation in Berlin. "
|
||||
"Acme Corporation manufactures bicycles."
|
||||
),
|
||||
"ontology_user_instruction": "Focus on person-organization-location relations.",
|
||||
"facts_user_instruction": "Extract employment and manufacturing facts.",
|
||||
}
|
||||
|
||||
with TestClient(app) as client:
|
||||
# /health & /info first as smoke
|
||||
assert client.get("/health").status_code == 200
|
||||
assert client.get("/info").status_code == 200
|
||||
|
||||
response = client.post(
|
||||
"/process",
|
||||
content=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["status"] == "success"
|
||||
assert body["data"]["ontology"], "ontology TTL must not be empty"
|
||||
assert body["data"]["facts"], "facts TTL must not be empty"
|
||||
|
||||
budget = body["metadata"]["budget"]
|
||||
assert budget["calls_count"] > 0, "BudgetTracker must record at least one LLM call"
|
||||
assert (
|
||||
budget["ontology_triples_generated"] > 0
|
||||
or budget["facts_triples_generated"] > 0
|
||||
), "BudgetTracker must record triple generation"
|
||||
|
||||
# The filesystem manager should have created artifacts somewhere under
|
||||
# working_directory. We don't assert exact filenames (those depend on
|
||||
# the document hash) — just that the directory is non-empty.
|
||||
work_dir = tmp_path / "work"
|
||||
written = list(work_dir.rglob("*"))
|
||||
assert any(p.suffix in {".ttl", ".rdf"} for p in written if p.is_file()), (
|
||||
f"No RDF artifacts produced under {work_dir}"
|
||||
)
|
||||
3
ontology_platform/tests/fixtures/sample_cryogenics.json
vendored
Normal file
3
ontology_platform/tests/fixtures/sample_cryogenics.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"text": "Intern: John A. Peterson Advisor: Dr. Michael Andersson\nInstitution: Department of Physics, University of Copenhagen\n\nAbstract\n\nLow-temperature physics, or cryogenics, is a field of physics that explores the behavior of materials at temperatures approaching absolute zero. This report presents an internship study focusing on the experimental investigation of superconductivity and quantum fluids at cryogenic temperatures. Using liquid helium-based cooling techniques, we examined the superconducting transition temperature of niobium samples and performed measurements on superfluid helium. Our study provides insights into the fundamental properties of quantum materials at ultra-low temperatures and highlights challenges in experimental cryogenics."
|
||||
}
|
||||
0
ontology_platform/tests/integration/__init__.py
Normal file
0
ontology_platform/tests/integration/__init__.py
Normal file
33
ontology_platform/tests/integration/conftest.py
Normal file
33
ontology_platform/tests/integration/conftest.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Shared fixtures for integration tests.
|
||||
|
||||
Sets up the Python path so `import platform.api.main` resolves the local
|
||||
package (not the stdlib `platform` module) and exposes helpers that turn
|
||||
the FastAPI app into a controllable test harness.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
VENDORED_ONTOCAST = REPO_ROOT / "vendored" / "ontocast"
|
||||
if str(VENDORED_ONTOCAST) not in sys.path:
|
||||
sys.path.insert(0, str(VENDORED_ONTOCAST))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fixtures_dir() -> Path:
|
||||
return REPO_ROOT / "tests" / "fixtures"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_json_path(fixtures_dir: Path) -> Path:
|
||||
path = fixtures_dir / "sample_cryogenics.json"
|
||||
assert path.exists(), f"Missing fixture: {path}"
|
||||
return path
|
||||
276
ontology_platform/tests/integration/test_api_smoke.py
Normal file
276
ontology_platform/tests/integration/test_api_smoke.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""FastAPI smoke tests for the Phase 0 endpoints.
|
||||
|
||||
These tests bypass real LLM/Toolbox initialization by injecting a mocked
|
||||
``AppContext`` via FastAPI's ``dependency_overrides``. They cover:
|
||||
|
||||
- /health 200 / 503
|
||||
- /info shape
|
||||
- /flush confirmation token enforcement
|
||||
- /process input validation (JSON / multipart / unsupported)
|
||||
- /process happy path with a fake workflow (no real LLM call)
|
||||
|
||||
A separate file (`test_api_real_workflow.py`) exercises the real OntoCast
|
||||
pipeline behind a marker that is skipped unless an LLM key is configured.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
VENDORED_ONTOCAST = REPO_ROOT / "vendored" / "ontocast"
|
||||
if str(VENDORED_ONTOCAST) not in sys.path:
|
||||
sys.path.insert(0, str(VENDORED_ONTOCAST))
|
||||
|
||||
main_module = importlib.import_module("platform.api.main")
|
||||
deps_module = importlib.import_module("platform.api.deps")
|
||||
|
||||
from ontocast.onto.enum import RenderMode # noqa: E402
|
||||
|
||||
|
||||
# ─── Mocked AppContext factory ────────────────────────────────────────────
|
||||
def _make_mock_context(workflow_chunks: list[dict[str, Any]]) -> SimpleNamespace:
|
||||
"""Build an AppContext stand-in.
|
||||
|
||||
``workflow_chunks`` is the sequence of state snapshots that the mock
|
||||
workflow.astream will yield. The last chunk becomes the response state.
|
||||
"""
|
||||
# Triple store and LLM are MagicMocks because /health/info just inspect them.
|
||||
triple_store = MagicMock()
|
||||
triple_store.clean = AsyncMock()
|
||||
|
||||
tools = SimpleNamespace(
|
||||
llm=MagicMock(),
|
||||
llm_provider="openai",
|
||||
triple_store_manager=triple_store,
|
||||
update_dataset=AsyncMock(),
|
||||
)
|
||||
|
||||
async def fake_astream(initial_state, *, stream_mode, config): # noqa: ARG001
|
||||
for chunk in workflow_chunks:
|
||||
yield chunk
|
||||
|
||||
workflow = SimpleNamespace(astream=fake_astream)
|
||||
|
||||
server_config = SimpleNamespace(
|
||||
render_mode=RenderMode.ONTOLOGY_AND_FACTS,
|
||||
max_visits_per_node=1,
|
||||
ontology_max_triples=1000,
|
||||
)
|
||||
|
||||
settings = SimpleNamespace(
|
||||
phase=0,
|
||||
storage_backend="filesystem",
|
||||
working_directory=REPO_ROOT / "data" / "working",
|
||||
)
|
||||
|
||||
return SimpleNamespace(
|
||||
settings=settings,
|
||||
tools=tools,
|
||||
workflow=workflow,
|
||||
server_config=server_config,
|
||||
recursion_limit=100,
|
||||
)
|
||||
|
||||
|
||||
def _client_with_context(ctx: SimpleNamespace) -> TestClient:
|
||||
"""Return a TestClient whose ``get_app_context`` returns the given ctx.
|
||||
|
||||
Bypassing the lifespan keeps tests fast and deterministic. We use
|
||||
``raise_server_exceptions=True`` (the default) so unexpected errors
|
||||
surface in test output.
|
||||
"""
|
||||
app = main_module.create_app()
|
||||
app.dependency_overrides[deps_module.get_app_context] = lambda: ctx
|
||||
# `TestClient` runs the lifespan by default; disable it because we're
|
||||
# providing the context manually.
|
||||
return TestClient(app, raise_server_exceptions=True, backend="asyncio")
|
||||
|
||||
|
||||
# ─── /health ──────────────────────────────────────────────────────────────
|
||||
def test_health_healthy() -> None:
|
||||
ctx = _make_mock_context([])
|
||||
with _client_with_context(ctx) as client:
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["status"] == "healthy"
|
||||
assert body["llm_provider"] == "openai"
|
||||
assert body["phase"] == 0
|
||||
assert body["storage_backend"] == "filesystem"
|
||||
|
||||
|
||||
def test_health_unhealthy_when_llm_missing() -> None:
|
||||
ctx = _make_mock_context([])
|
||||
ctx.tools.llm = None
|
||||
with _client_with_context(ctx) as client:
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 503
|
||||
assert response.json()["status"] == "unhealthy"
|
||||
|
||||
|
||||
# ─── /info ────────────────────────────────────────────────────────────────
|
||||
def test_info_shape() -> None:
|
||||
ctx = _make_mock_context([])
|
||||
with _client_with_context(ctx) as client:
|
||||
response = client.get("/info")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
for key in ("name", "platform_version", "capabilities", "input_types", "output_types"):
|
||||
assert key in body, f"missing key: {key}"
|
||||
assert "text-to-triples" in body["capabilities"]
|
||||
|
||||
|
||||
# ─── /flush ───────────────────────────────────────────────────────────────
|
||||
def test_flush_requires_confirmation_token() -> None:
|
||||
ctx = _make_mock_context([])
|
||||
with _client_with_context(ctx) as client:
|
||||
response = client.post("/flush")
|
||||
assert response.status_code == 400
|
||||
assert "confirm" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_flush_with_correct_confirmation_succeeds() -> None:
|
||||
ctx = _make_mock_context([])
|
||||
with _client_with_context(ctx) as client:
|
||||
response = client.post(
|
||||
"/flush",
|
||||
params={"confirm": "YES-I-WANT-TO-DELETE-EVERYTHING"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
ctx.tools.triple_store_manager.clean.assert_awaited_once_with(dataset=None)
|
||||
|
||||
|
||||
# ─── /process — input validation ──────────────────────────────────────────
|
||||
def test_process_rejects_unsupported_content_type() -> None:
|
||||
ctx = _make_mock_context([])
|
||||
with _client_with_context(ctx) as client:
|
||||
response = client.post(
|
||||
"/process",
|
||||
content="raw text",
|
||||
headers={"Content-Type": "text/plain"},
|
||||
)
|
||||
assert response.status_code == 415
|
||||
|
||||
|
||||
def test_process_rejects_empty_json_body() -> None:
|
||||
ctx = _make_mock_context([])
|
||||
with _client_with_context(ctx) as client:
|
||||
response = client.post(
|
||||
"/process",
|
||||
content=b"",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
# ─── /process — happy path with fake workflow ─────────────────────────────
|
||||
def _fake_workflow_state() -> dict[str, Any]:
|
||||
"""Build a minimal workflow state that /process can serialize."""
|
||||
onto_graph = MagicMock()
|
||||
onto_graph.serialize = MagicMock(return_value="@prefix ex: <urn:ex#> .")
|
||||
facts_graph = MagicMock()
|
||||
facts_graph.serialize = MagicMock(return_value="@prefix ex: <urn:ex#> . ex:a ex:b ex:c .")
|
||||
|
||||
budget_tracker = MagicMock()
|
||||
budget_tracker.model_dump = MagicMock(
|
||||
return_value={
|
||||
"chars_sent": 42,
|
||||
"chars_received": 24,
|
||||
"calls_count": 3,
|
||||
"ontology_triples_generated": 5,
|
||||
"facts_triples_generated": 7,
|
||||
"ontology_operations_count": 1,
|
||||
"facts_operations_count": 2,
|
||||
}
|
||||
)
|
||||
|
||||
current_ontology = MagicMock()
|
||||
current_ontology.graph = onto_graph
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"content_units": [MagicMock(), MagicMock()],
|
||||
"parallel_facts_units": [MagicMock(), MagicMock()],
|
||||
"render_mode": RenderMode.ONTOLOGY_AND_FACTS,
|
||||
"current_ontology": current_ontology,
|
||||
"aggregated_facts": facts_graph,
|
||||
"budget_tracker": budget_tracker,
|
||||
}
|
||||
|
||||
|
||||
def test_process_json_envelope_returns_ontology_and_facts(
|
||||
sample_json_path: Path,
|
||||
) -> None:
|
||||
workflow_state = _fake_workflow_state()
|
||||
ctx = _make_mock_context([workflow_state])
|
||||
|
||||
payload = json.loads(sample_json_path.read_text(encoding="utf-8"))
|
||||
|
||||
with _client_with_context(ctx) as client:
|
||||
response = client.post(
|
||||
"/process",
|
||||
content=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["status"] == "success"
|
||||
assert body["data"]["ontology"].startswith("@prefix")
|
||||
assert body["data"]["facts"].startswith("@prefix")
|
||||
assert body["metadata"]["chunks_processed"] == 2
|
||||
# Budget tracker must surface, otherwise Acceptance Gate 0 fails.
|
||||
assert body["metadata"]["budget"]["calls_count"] == 3
|
||||
assert body["metadata"]["budget"]["facts_triples_generated"] == 7
|
||||
|
||||
|
||||
def test_process_multipart_upload(sample_json_path: Path) -> None:
|
||||
workflow_state = _fake_workflow_state()
|
||||
ctx = _make_mock_context([workflow_state])
|
||||
|
||||
with _client_with_context(ctx) as client:
|
||||
with sample_json_path.open("rb") as fh:
|
||||
response = client.post(
|
||||
"/process",
|
||||
files={"file": (sample_json_path.name, fh, "application/octet-stream")},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["status"] == "success"
|
||||
assert body["data"]["ontology"]
|
||||
|
||||
|
||||
def test_process_workflow_exception_returns_500() -> None:
|
||||
"""If the workflow raises, /process must return 500 with error details."""
|
||||
|
||||
async def boom(initial_state, *, stream_mode, config): # noqa: ARG001
|
||||
if False: # pragma: no cover — makes this a generator function
|
||||
yield {}
|
||||
raise RuntimeError("simulated failure")
|
||||
|
||||
ctx = _make_mock_context([])
|
||||
ctx.workflow = SimpleNamespace(astream=boom)
|
||||
|
||||
with _client_with_context(ctx) as client:
|
||||
response = client.post(
|
||||
"/process",
|
||||
content=json.dumps({"text": "anything"}),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert response.status_code == 500
|
||||
body = response.json()
|
||||
assert body["status"] == "error"
|
||||
assert "simulated failure" in body["error"]
|
||||
0
ontology_platform/tests/unit/__init__.py
Normal file
0
ontology_platform/tests/unit/__init__.py
Normal file
200
ontology_platform/tests/unit/test_convert_document.py
Normal file
200
ontology_platform/tests/unit/test_convert_document.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""Regression tests for the OntoCast `convert_document` agent.
|
||||
|
||||
Covers the multi-file corpus extension described in
|
||||
`docs/통합설계서.md` §5 Phase 0 and OntoCast 분석 §13.1 / §21.1.
|
||||
|
||||
Original behavior: the loop overwrote `state.input_text` on every iteration,
|
||||
so multi-file input silently lost all but the last file. The fix accumulates
|
||||
into one corpus with explicit file-boundary separators while keeping
|
||||
single-file behavior byte-identical.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
VENDORED_ONTOCAST = REPO_ROOT / "vendored" / "ontocast"
|
||||
if str(VENDORED_ONTOCAST) not in sys.path:
|
||||
sys.path.insert(0, str(VENDORED_ONTOCAST))
|
||||
|
||||
from ontocast.agent import convert_document as convert_document_module # noqa: E402
|
||||
from ontocast.onto.enum import Status # noqa: E402
|
||||
|
||||
|
||||
class _StubConverter:
|
||||
"""Minimal stand-in for `ConverterTool`.
|
||||
|
||||
Accepts a fake PDF/DOCX file (any bytes) and returns a dict shaped like
|
||||
the real converter's output.
|
||||
"""
|
||||
|
||||
supported_extensions = {".pdf", ".docx"}
|
||||
|
||||
def __init__(self, mapping: dict[bytes, str]) -> None:
|
||||
self._mapping = mapping
|
||||
|
||||
def __call__(self, file_content: bytes) -> dict[str, str]:
|
||||
return {"text": self._mapping[file_content]}
|
||||
|
||||
|
||||
def _make_state(files: dict[str, bytes]) -> SimpleNamespace:
|
||||
"""Build a lightweight stand-in for `AgentState`.
|
||||
|
||||
We intentionally avoid constructing the real Pydantic model here — its
|
||||
initialization touches many unrelated fields and tools. We only mirror
|
||||
the attributes that `convert_document` reads or writes.
|
||||
"""
|
||||
captured_text: list[str] = []
|
||||
|
||||
def set_text(text: str) -> None:
|
||||
captured_text.append(text)
|
||||
|
||||
state = SimpleNamespace(
|
||||
files=files,
|
||||
status=None,
|
||||
input_text="",
|
||||
ontology_user_instruction="",
|
||||
facts_user_instruction="",
|
||||
source_url=None,
|
||||
set_text=set_text,
|
||||
_captured_text=captured_text,
|
||||
)
|
||||
return state
|
||||
|
||||
|
||||
def _make_tools(converter_mapping: dict[bytes, str]) -> SimpleNamespace:
|
||||
return SimpleNamespace(converter=_StubConverter(converter_mapping))
|
||||
|
||||
|
||||
# ─── Case 1: single PDF — output must equal the file's text verbatim ─────
|
||||
def test_single_pdf_passes_through_unchanged() -> None:
|
||||
pdf_bytes = b"%PDF-1.4 fake"
|
||||
state = _make_state({"sample.pdf": pdf_bytes})
|
||||
tools = _make_tools({pdf_bytes: "Hello from PDF."})
|
||||
|
||||
result = convert_document_module.convert_document(state, tools)
|
||||
|
||||
assert result.status == Status.SUCCESS
|
||||
assert state._captured_text == ["Hello from PDF."]
|
||||
|
||||
|
||||
# ─── Case 2: single JSON — text + corpus metadata flows through ──────────
|
||||
def test_single_json_extracts_metadata_and_text() -> None:
|
||||
payload = {
|
||||
"text": "Body of the article.",
|
||||
"url": "https://example.com/a",
|
||||
"ontology_user_instruction": "Focus on organizations.",
|
||||
"facts_user_instruction": "Extract person-org links.",
|
||||
}
|
||||
state = _make_state({"a.json": json.dumps(payload).encode("utf-8")})
|
||||
tools = _make_tools({})
|
||||
|
||||
result = convert_document_module.convert_document(state, tools)
|
||||
|
||||
assert result.status == Status.SUCCESS
|
||||
assert state._captured_text == ["Body of the article."]
|
||||
assert state.source_url == "https://example.com/a"
|
||||
assert state.ontology_user_instruction == "Focus on organizations."
|
||||
assert state.facts_user_instruction == "Extract person-org links."
|
||||
|
||||
|
||||
# ─── Case 3: multiple PDFs — both bodies survive with boundary marker ────
|
||||
def test_multiple_pdfs_are_concatenated_with_boundary() -> None:
|
||||
"""The legacy bug: only the last file's text survived. After the fix,
|
||||
both texts appear in the corpus separated by `=== File: <name> ===`."""
|
||||
pdf_a = b"%PDF-1.4 A"
|
||||
pdf_b = b"%PDF-1.4 B"
|
||||
state = _make_state({"a.pdf": pdf_a, "b.pdf": pdf_b})
|
||||
tools = _make_tools({pdf_a: "Text A.", pdf_b: "Text B."})
|
||||
|
||||
convert_document_module.convert_document(state, tools)
|
||||
|
||||
corpus = state._captured_text[-1]
|
||||
assert "Text A." in corpus
|
||||
assert "Text B." in corpus
|
||||
assert "=== File: a.pdf ===" in corpus
|
||||
assert "=== File: b.pdf ===" in corpus
|
||||
# Ordering: a.pdf before b.pdf (insertion order preserved)
|
||||
assert corpus.index("Text A.") < corpus.index("Text B.")
|
||||
|
||||
|
||||
# ─── Case 4: multiple JSONs — first-wins for corpus metadata ─────────────
|
||||
def test_multiple_jsons_keep_first_metadata() -> None:
|
||||
"""`ontology_user_instruction`, `facts_user_instruction`, `source_url`
|
||||
are corpus-level singletons. The first JSON file that provides each
|
||||
wins; later JSONs do not overwrite."""
|
||||
payload_a = {
|
||||
"text": "Body A.",
|
||||
"url": "https://first.example/a",
|
||||
"ontology_user_instruction": "First instruction.",
|
||||
"facts_user_instruction": "First facts.",
|
||||
}
|
||||
payload_b = {
|
||||
"text": "Body B.",
|
||||
"url": "https://second.example/b",
|
||||
"ontology_user_instruction": "Second instruction (must be ignored).",
|
||||
"facts_user_instruction": "Second facts (must be ignored).",
|
||||
}
|
||||
state = _make_state(
|
||||
{
|
||||
"a.json": json.dumps(payload_a).encode("utf-8"),
|
||||
"b.json": json.dumps(payload_b).encode("utf-8"),
|
||||
}
|
||||
)
|
||||
tools = _make_tools({})
|
||||
|
||||
convert_document_module.convert_document(state, tools)
|
||||
|
||||
assert state.source_url == "https://first.example/a"
|
||||
assert state.ontology_user_instruction == "First instruction."
|
||||
assert state.facts_user_instruction == "First facts."
|
||||
corpus = state._captured_text[-1]
|
||||
assert "Body A." in corpus and "Body B." in corpus
|
||||
|
||||
|
||||
# ─── Case 5: unsupported extension fails fast ────────────────────────────
|
||||
def test_unsupported_extension_returns_failed() -> None:
|
||||
state = _make_state({"weird.xyz": b"???"})
|
||||
tools = _make_tools({})
|
||||
|
||||
result = convert_document_module.convert_document(state, tools)
|
||||
|
||||
assert result.status == Status.FAILED
|
||||
|
||||
|
||||
# ─── Case 6: empty files dict is a no-op success ─────────────────────────
|
||||
def test_empty_files_is_noop_success() -> None:
|
||||
state = _make_state({})
|
||||
tools = _make_tools({})
|
||||
|
||||
result = convert_document_module.convert_document(state, tools)
|
||||
|
||||
assert result.status == Status.SUCCESS
|
||||
assert state._captured_text == [] # set_text never called
|
||||
|
||||
|
||||
# ─── Case 7: mixed PDF + JSON in one corpus ──────────────────────────────
|
||||
def test_mixed_pdf_and_json_combine_with_boundaries() -> None:
|
||||
pdf_bytes = b"%PDF-1.4 mix"
|
||||
json_payload = {"text": "JSON body."}
|
||||
state = _make_state(
|
||||
{
|
||||
"a.pdf": pdf_bytes,
|
||||
"b.json": json.dumps(json_payload).encode("utf-8"),
|
||||
}
|
||||
)
|
||||
tools = _make_tools({pdf_bytes: "PDF body."})
|
||||
|
||||
convert_document_module.convert_document(state, tools)
|
||||
|
||||
corpus = state._captured_text[-1]
|
||||
assert "=== File: a.pdf ===" in corpus
|
||||
assert "=== File: b.json ===" in corpus
|
||||
assert "PDF body." in corpus
|
||||
assert "JSON body." in corpus
|
||||
120
ontology_platform/tests/unit/test_platform_config.py
Normal file
120
ontology_platform/tests/unit/test_platform_config.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Tests for `platform.config`.
|
||||
|
||||
Covers:
|
||||
- Phase 0 forces filesystem backend even when NEO4J_*/FUSEKI_* are set
|
||||
- `build_ontocast_config` clears Neo4j/Fuseki on filesystem backend
|
||||
- Phase < 4 with non-filesystem storage_backend raises eagerly
|
||||
- working_directory is auto-created
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
PLATFORM_ROOT = REPO_ROOT
|
||||
if str(PLATFORM_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PLATFORM_ROOT))
|
||||
|
||||
# Importing `platform.config` here works because the `platform/` package
|
||||
# directory has an `__init__.py`. (Avoid the stdlib module of the same name
|
||||
# by relying on the package being on sys.path before site-packages.)
|
||||
import importlib
|
||||
|
||||
platform_config = importlib.import_module("platform.config")
|
||||
|
||||
|
||||
def _clear_settings_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Wipe all env vars that PlatformSettings or OntoCast Config might read."""
|
||||
for var in [
|
||||
"PHASE",
|
||||
"STORAGE_BACKEND",
|
||||
"ONTOCAST_WORKING_DIRECTORY",
|
||||
"ONTOCAST_ONTOLOGY_DIRECTORY",
|
||||
"HOST",
|
||||
"PORT",
|
||||
"LOG_LEVEL",
|
||||
"ROBOTS_POLICY",
|
||||
"NEO4J_URI",
|
||||
"NEO4J_AUTH",
|
||||
"FUSEKI_URI",
|
||||
"FUSEKI_AUTH",
|
||||
"LLM_PROVIDER",
|
||||
"LLM_API_KEY",
|
||||
"LLM_MODEL_NAME",
|
||||
]:
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
|
||||
def test_default_phase_is_base_with_filesystem(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
_clear_settings_env(monkeypatch)
|
||||
monkeypatch.setenv("ONTOCAST_WORKING_DIRECTORY", str(tmp_path / "work"))
|
||||
|
||||
settings = platform_config.PlatformSettings() # type: ignore[call-arg]
|
||||
|
||||
assert settings.phase == platform_config.Phase.BASE
|
||||
assert settings.storage_backend == "filesystem"
|
||||
assert (tmp_path / "work").exists(), "working_directory should be created"
|
||||
|
||||
|
||||
def test_phase0_rejects_neo4j_backend(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_clear_settings_env(monkeypatch)
|
||||
monkeypatch.setenv("PHASE", "0")
|
||||
monkeypatch.setenv("STORAGE_BACKEND", "neo4j")
|
||||
|
||||
with pytest.raises(ValueError, match="requires Phase 4"):
|
||||
platform_config.PlatformSettings() # type: ignore[call-arg]
|
||||
|
||||
|
||||
def test_phase0_rejects_fuseki_backend(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_clear_settings_env(monkeypatch)
|
||||
monkeypatch.setenv("PHASE", "0")
|
||||
monkeypatch.setenv("STORAGE_BACKEND", "fuseki")
|
||||
|
||||
with pytest.raises(ValueError, match="requires Phase 4"):
|
||||
platform_config.PlatformSettings() # type: ignore[call-arg]
|
||||
|
||||
|
||||
def test_build_ontocast_config_clears_neo4j_and_fuseki(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""Even if NEO4J_*/FUSEKI_* env vars are set, Phase 0 must hand
|
||||
OntoCast a config with both backends disabled."""
|
||||
_clear_settings_env(monkeypatch)
|
||||
monkeypatch.setenv("ONTOCAST_WORKING_DIRECTORY", str(tmp_path / "work"))
|
||||
# Set fake credentials that would otherwise enable both backends.
|
||||
monkeypatch.setenv("NEO4J_URI", "bolt://localhost:7687")
|
||||
monkeypatch.setenv("NEO4J_AUTH", "neo4j/changeme")
|
||||
monkeypatch.setenv("FUSEKI_URI", "http://localhost:3030")
|
||||
monkeypatch.setenv("FUSEKI_AUTH", "admin:changeme")
|
||||
# LLM must be valid enough for validate_llm_config to pass.
|
||||
monkeypatch.setenv("LLM_PROVIDER", "openai")
|
||||
monkeypatch.setenv("LLM_API_KEY", "test-key")
|
||||
|
||||
settings = platform_config.PlatformSettings() # type: ignore[call-arg]
|
||||
cfg = platform_config.build_ontocast_config(settings)
|
||||
|
||||
assert cfg.tool_config.neo4j.uri is None
|
||||
assert cfg.tool_config.neo4j.auth is None
|
||||
assert cfg.tool_config.fuseki.uri is None
|
||||
assert cfg.tool_config.fuseki.auth is None
|
||||
# Paths must be the platform-overridden value.
|
||||
assert cfg.tool_config.path_config.working_directory == (tmp_path / "work")
|
||||
|
||||
|
||||
def test_build_ontocast_config_validates_llm_eagerly(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
_clear_settings_env(monkeypatch)
|
||||
monkeypatch.setenv("ONTOCAST_WORKING_DIRECTORY", str(tmp_path / "work"))
|
||||
monkeypatch.setenv("LLM_PROVIDER", "openai")
|
||||
# Intentionally omit LLM_API_KEY so validate_llm_config raises.
|
||||
|
||||
settings = platform_config.PlatformSettings() # type: ignore[call-arg]
|
||||
with pytest.raises(ValueError, match="LLM_API_KEY"):
|
||||
platform_config.build_ontocast_config(settings)
|
||||
178
ontology_platform/tests/unit/test_select_ontology.py
Normal file
178
ontology_platform/tests/unit/test_select_ontology.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""Regression tests for the OntoCast `select_ontology` agent.
|
||||
|
||||
Covers the None-selection index fix described in
|
||||
`docs/통합설계서.md` §5 Phase 0 and OntoCast 분석 §13.1.
|
||||
|
||||
The original code checked ``answer_index == 0`` for "None", but the Pydantic
|
||||
dynamic model constrains ``answer_index`` to ``[1, num_ontologies + 1]``.
|
||||
The fix maps ``num_ontologies + 1`` to ``NULL_ONTOLOGY`` and removes the
|
||||
unreachable ``answer_index == 0`` branch.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
# Make the vendored OntoCast importable. The repo layout is:
|
||||
# ontology_platform/
|
||||
# vendored/ontocast/ontocast/...
|
||||
# We point at `vendored/ontocast` so `import ontocast.<x>` resolves.
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
VENDORED_ONTOCAST = REPO_ROOT / "vendored" / "ontocast"
|
||||
if str(VENDORED_ONTOCAST) not in sys.path:
|
||||
sys.path.insert(0, str(VENDORED_ONTOCAST))
|
||||
|
||||
# Imports must come after sys.path tweak.
|
||||
from ontocast.agent import select_ontology as select_ontology_module # noqa: E402
|
||||
from ontocast.onto.enum import Status # noqa: E402
|
||||
from ontocast.onto.null import NULL_ONTOLOGY # noqa: E402
|
||||
|
||||
|
||||
def _make_state(num_content_units: int = 1) -> SimpleNamespace:
|
||||
"""Build a minimal stand-in for `AgentState` used by `select_ontology`.
|
||||
|
||||
Only attributes accessed inside the function are populated. Using
|
||||
`SimpleNamespace` avoids constructing the full Pydantic model and all
|
||||
its nested defaults, which would couple the test to OntoCast internals
|
||||
far beyond the scope of this regression.
|
||||
"""
|
||||
units = [SimpleNamespace(text=f"chunk {i} text") for i in range(num_content_units)]
|
||||
return SimpleNamespace(
|
||||
current_ontology=NULL_ONTOLOGY,
|
||||
content_units=units,
|
||||
current_content_unit=units[0] if units else None,
|
||||
input_text="",
|
||||
status=None,
|
||||
# `get_content_unit_progress_string` is only used for logging
|
||||
get_content_unit_progress_string=lambda: f"{num_content_units} units",
|
||||
)
|
||||
|
||||
|
||||
def _make_tools(ontologies: list) -> SimpleNamespace:
|
||||
om = MagicMock()
|
||||
om.has_ontologies = bool(ontologies)
|
||||
om.ontologies = ontologies
|
||||
return SimpleNamespace(llm=MagicMock(), ontology_manager=om)
|
||||
|
||||
|
||||
def _patch_llm_call(monkeypatch: pytest.MonkeyPatch, answer_index: int) -> None:
|
||||
"""Patch `call_llm_with_retry` to return a stub selector with the given index."""
|
||||
selector_stub = SimpleNamespace(answer_index=answer_index)
|
||||
monkeypatch.setattr(
|
||||
select_ontology_module,
|
||||
"call_llm_with_retry",
|
||||
AsyncMock(return_value=selector_stub),
|
||||
)
|
||||
|
||||
|
||||
# ─── Case 1: no ontologies available ──────────────────────────────────────
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_ontologies_returns_null(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When no ontologies are registered, the LLM is not called and the
|
||||
current ontology stays NULL."""
|
||||
state = _make_state()
|
||||
tools = _make_tools(ontologies=[])
|
||||
|
||||
# Guard: ensure LLM is never invoked when there is nothing to pick from.
|
||||
sentinel = AsyncMock()
|
||||
monkeypatch.setattr(select_ontology_module, "call_llm_with_retry", sentinel)
|
||||
|
||||
result = await select_ontology_module.select_ontology(state, tools)
|
||||
|
||||
assert result.current_ontology is NULL_ONTOLOGY
|
||||
sentinel.assert_not_called()
|
||||
|
||||
|
||||
# ─── Case 2: LLM picks a valid index ──────────────────────────────────────
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_picks_valid_index(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When the LLM returns an index within [1, num_ontologies], the
|
||||
corresponding ontology is selected (0-based after subtraction)."""
|
||||
onto_a = MagicMock(name="onto_a", ontology_id="onto-a", iri="urn:a")
|
||||
onto_a.is_null = MagicMock(return_value=False)
|
||||
onto_a.initial_version = None
|
||||
onto_a.version = "1.0"
|
||||
onto_a.describe = MagicMock(return_value="ontology A description")
|
||||
|
||||
onto_b = MagicMock(name="onto_b", ontology_id="onto-b", iri="urn:b")
|
||||
onto_b.is_null = MagicMock(return_value=False)
|
||||
onto_b.initial_version = None
|
||||
onto_b.version = "1.0"
|
||||
onto_b.describe = MagicMock(return_value="ontology B description")
|
||||
|
||||
state = _make_state()
|
||||
tools = _make_tools(ontologies=[onto_a, onto_b])
|
||||
|
||||
# LLM picks the second ontology (1-based index 2)
|
||||
_patch_llm_call(monkeypatch, answer_index=2)
|
||||
|
||||
result = await select_ontology_module.select_ontology(state, tools)
|
||||
|
||||
assert result.current_ontology is onto_b
|
||||
assert result.status == Status.SUCCESS
|
||||
|
||||
|
||||
# ─── Case 3: LLM picks None (num_ontologies + 1) — the bug fix case ──────
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_picks_none_index_returns_null_without_warning(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""When the LLM returns `num_ontologies + 1` (the encoded "None" choice),
|
||||
the ontology must be set to NULL_ONTOLOGY **and** no WARNING should be
|
||||
emitted. The pre-fix code logged a warning on every legitimate None
|
||||
selection because the path fell through to the defensive branch.
|
||||
"""
|
||||
onto_a = MagicMock(ontology_id="onto-a", iri="urn:a")
|
||||
onto_a.is_null = MagicMock(return_value=False)
|
||||
onto_a.initial_version = None
|
||||
onto_a.version = "1.0"
|
||||
onto_a.describe = MagicMock(return_value="ontology A description")
|
||||
|
||||
state = _make_state()
|
||||
tools = _make_tools(ontologies=[onto_a])
|
||||
|
||||
# num_ontologies = 1, so None is encoded as 2.
|
||||
_patch_llm_call(monkeypatch, answer_index=2)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="ontocast.agent.select_ontology"):
|
||||
result = await select_ontology_module.select_ontology(state, tools)
|
||||
|
||||
assert result.current_ontology is NULL_ONTOLOGY
|
||||
assert result.status == Status.SUCCESS
|
||||
warnings = [r for r in caplog.records if r.levelno >= logging.WARNING]
|
||||
assert warnings == [], f"Unexpected warnings: {[r.message for r in warnings]}"
|
||||
|
||||
|
||||
# ─── Case 4: defensive fallback for out-of-range index ───────────────────
|
||||
@pytest.mark.asyncio
|
||||
async def test_out_of_range_index_falls_back_to_null(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""If the LLM somehow returns a value outside [1, num_ontologies + 1]
|
||||
(Pydantic should prevent this, but be defensive), the function must
|
||||
log a WARNING and default to NULL_ONTOLOGY."""
|
||||
onto_a = MagicMock(ontology_id="onto-a", iri="urn:a")
|
||||
onto_a.is_null = MagicMock(return_value=False)
|
||||
onto_a.initial_version = None
|
||||
onto_a.version = "1.0"
|
||||
onto_a.describe = MagicMock(return_value="ontology A description")
|
||||
|
||||
state = _make_state()
|
||||
tools = _make_tools(ontologies=[onto_a])
|
||||
|
||||
# Out of range: num_ontologies = 1, valid is {1, 2}; we pass 99.
|
||||
_patch_llm_call(monkeypatch, answer_index=99)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="ontocast.agent.select_ontology"):
|
||||
result = await select_ontology_module.select_ontology(state, tools)
|
||||
|
||||
assert result.current_ontology is NULL_ONTOLOGY
|
||||
warnings = [r for r in caplog.records if r.levelno >= logging.WARNING]
|
||||
assert len(warnings) == 1
|
||||
assert "Out-of-range answer_index" in warnings[0].message
|
||||
Reference in New Issue
Block a user