Files
AI/ontology_platform/tests/unit/test_select_ontology.py
lasta ec4f9a64f6 Phase 0.7 — Acceptance Gate 자동화 + LM Studio 통합 + OntoCast 버그 수정
- platform/ → ont_platform/ rename
  Python 내장 platform 모듈과 이름 충돌. numpy/scipy가 platform.machine() 호출 시
  우리 패키지를 가져와 AttributeError. ont_platform으로 변경하고 pyproject.toml,
  ont_platform/**, tests/** import 경로 모두 업데이트.

- ont_platform/config.py: lenient LLM builder 추가
  LM Studio/vLLM 등 OpenAI-호환 로컬 서버가 임의 모델 식별자(예: deepseek-r1-distill-
  qwen-7b)를 쓸 수 있도록 OntoCast의 OpenAIModel enum validation을 Pydantic
  model_construct로 우회. ToolConfig() 생성 시 충돌을 막기 위해 LLM_MODEL_NAME을
  잠시 비웠다가 lenient 인스턴스로 교체.

- ont_platform/api/deps.py: ToolBox 초기화를 asyncio.to_thread로 격리
  LLMTool.create()가 내부에서 asyncio.run()을 부르는데 lifespan/테스트가 이미
  async 컨텍스트라 이중 loop 충돌. 별도 스레드에서 sync 생성자 실행.

- 테스트 인프라 정비
  * tests/integration/test_api_smoke.py: TestClient 구버전 starlette 호환을 위해
    lifespan='off' 대신 app.router.lifespan_context = noop 패턴 적용.
  * tests/unit/test_convert_document.py, test_select_ontology.py: ontocast.agent
    __init__.py가 re-export한 함수가 서브모듈을 가리는 문제로 sys.modules에서
    실제 모듈 객체 직접 추출.
  * tests/e2e/conftest.py: .env 자동 로드 + provider별 skip 조건 (Ollama는
    LLM_API_KEY 불필요).
  * tests/e2e/test_phase0_full_pipeline.py: provider별 키 분기,
    HDBSCAN 클러스터링이 동작하도록 fixture 페이로드 16문장으로 확장.

- vendored OntoCast 버그 수정 3건 (VENDORED_MODIFICATIONS.md 기록):
  * agent/render_ontology.py: render_ontology_fresh()의 .format() 호출에 누락된
    ontology_prefix 인자 추가 (Bootstrap 단계에서 KeyError: 'ontology_prefix').
  * stategraph/node_factories.py: render_ontology/render_facts 노드의
    state.model_copy(deep=True)로 budget_tracker가 deep-copy되어 root state의
    BudgetTracker가 영원히 0인 채로 남던 버그 수정. 원본 인스턴스 공유로 변경.

- 문서 갱신
  README.md (Phase 0.7 부분완료 + ont_platform 폴더 이름),
  docs/phases/PHASE0_ACCEPTANCE_GATE.md (검증 이력 + Ollama/LM Studio 옵션),
  .env.example (LM Studio/Ollama/OpenAI 세 옵션 명시).

검증
- unit + integration 26/26 통과.
- e2e (LM Studio + Qwen3-8B / DeepSeek-R1-Distill-Qwen-7B): 워크플로우 끝까지
  실행 + 5번 LLM 호출 + LangGraph 전 노드 traceable 확인. 7-8B 로컬 모델은
  strict structured output(Turtle RDF in JSON) 한계로 ontology/facts TTL 자동
  생성 부분 성공. 클라우드 LLM 환경에서 재검증 필요.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-14 09:05:24 +09:00

183 lines
7.4 KiB
Python

"""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.
import sys
import ontocast.agent # __init__.py 실행으로 서브모듈이 sys.modules에 등록됨 # noqa: E402
select_ontology_module = sys.modules["ontocast.agent.select_ontology"]
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