2026-05-13 19:57:34 +09:00
|
|
|
"""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.
|
2026-05-14 09:05:24 +09:00
|
|
|
import sys
|
|
|
|
|
|
|
|
|
|
import ontocast.agent # __init__.py 실행으로 서브모듈이 sys.modules에 등록됨 # noqa: E402
|
|
|
|
|
|
|
|
|
|
select_ontology_module = sys.modules["ontocast.agent.select_ontology"]
|
2026-05-13 19:57:34 +09:00
|
|
|
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
|