"""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.` 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