Files
AI/ontology_platform/tests/integration/test_api_smoke.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

282 lines
10 KiB
Python

"""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 contextlib import asynccontextmanager
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("ont_platform.api.main")
deps_module = importlib.import_module("ont_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,
)
@asynccontextmanager
async def _noop_lifespan(app): # noqa: ARG001
yield
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.router.lifespan_context = _noop_lifespan
app.dependency_overrides[deps_module.get_app_context] = lambda: ctx
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"]