This commit is contained in:
LASTA_DEV01\lasta
2026-05-13 19:57:34 +09:00
parent 2e9204243d
commit 9e88f4c7ad
4310 changed files with 48538 additions and 905279 deletions

View File

View 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"]

View File

View 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",
)

View 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}"
)

View 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."
}

View 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

View 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"]

View File

View 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

View 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)

View 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