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>
This commit is contained in:
lasta
2026-05-14 09:05:24 +09:00
parent 9e88f4c7ad
commit ec4f9a64f6
26 changed files with 237 additions and 55 deletions

View File

@@ -4,12 +4,34 @@
# ─── Phase 0: OntoCast Base ───────────────────────────────────────── # ─── Phase 0: OntoCast Base ─────────────────────────────────────────
ONTOCAST_WORKING_DIRECTORY=./data/working ONTOCAST_WORKING_DIRECTORY=./data/working
# LLM Provider (OpenAI 또는 Ollama) # ─── LLM Provider ───────────────────────────────────────────────────
# 셋 중 하나를 골라 주석을 해제. ont_platform/config.py의 lenient 빌더가
# OntoCast OpenAIModel enum 제약을 우회하므로, LM Studio처럼 임의 모델명도
# LLM_MODEL_NAME에 그대로 적으면 된다.
# [옵션 A] LM Studio (현재 기본) — http://127.0.0.1:1234
# LM Studio의 "Developer > Reachable at" 주소를 그대로 LLM_BASE_URL에 입력.
# LLM_MODEL_NAME은 LM Studio가 로드한 모델 식별자와 일치시킨다.
LLM_PROVIDER=openai LLM_PROVIDER=openai
LLM_MODEL_NAME=gpt-4o-mini LLM_MODEL_NAME=deepseek-r1-distill-qwen-7b
LLM_BASE_URL=http://127.0.0.1:1234/v1
LLM_API_KEY=lm-studio
LLM_TEMPERATURE=0.0 LLM_TEMPERATURE=0.0
LLM_API_KEY=
LLM_BASE_URL= # [옵션 B] Ollama 로컬 (https://ollama.com)
# 사전: `ollama pull qwen2.5` 등
# LLM_PROVIDER=ollama
# LLM_MODEL_NAME=qwen2.5
# LLM_BASE_URL=http://localhost:11434
# LLM_API_KEY=
# LLM_TEMPERATURE=0.0
# [옵션 C] OpenAI 클라우드
# LLM_PROVIDER=openai
# LLM_MODEL_NAME=gpt-4o-mini
# LLM_BASE_URL=
# LLM_API_KEY=sk-...
# LLM_TEMPERATURE=0.0
# ─── Server ───────────────────────────────────────────────────────── # ─── Server ─────────────────────────────────────────────────────────
PORT=8000 PORT=8000

View File

@@ -38,7 +38,7 @@ docker compose up -d fuseki postgres redis # Phase 0~3
| 0.4 | ✅ 완료 | Robyn → FastAPI 재작성 (`/health`, `/info`, `/process`, `/flush`) | | 0.4 | ✅ 완료 | Robyn → FastAPI 재작성 (`/health`, `/info`, `/process`, `/flush`) |
| 0.5 | ✅ 완료 | Pydantic Settings 정리 (filesystem 모드 강제) + 테스트 5개 | | 0.5 | ✅ 완료 | Pydantic Settings 정리 (filesystem 모드 강제) + 테스트 5개 |
| 0.6 | ✅ 완료 | 통합 테스트(10개) + E2E 테스트(marker 분리) 작성 | | 0.6 | ✅ 완료 | 통합 테스트(10개) + E2E 테스트(marker 분리) 작성 |
| 0.7 | ⚠️ 대기 | Acceptance Gate 0 — 실 환경에서 `pytest` 실행 필요 ([PHASE0_ACCEPTANCE_GATE.md](docs/phases/PHASE0_ACCEPTANCE_GATE.md)) | | 0.7 | 🟡 부분완료 | Gate #2/#4 ✅ (unit 16/16, integration 10/10 통과). #1/#3 e2e 대기 ([PHASE0_ACCEPTANCE_GATE.md](docs/phases/PHASE0_ACCEPTANCE_GATE.md)) |
**Next**: Acceptance Gate 0를 통과한 후 [PHASE1_NEXT_STEPS.md](docs/phases/PHASE1_NEXT_STEPS.md)로 진행. **Next**: Acceptance Gate 0를 통과한 후 [PHASE1_NEXT_STEPS.md](docs/phases/PHASE1_NEXT_STEPS.md)로 진행.
@@ -58,7 +58,7 @@ ontology_platform/
│ └── phases/ ← Phase별 작업 로그 │ └── phases/ ← Phase별 작업 로그
├── vendored/ ├── vendored/
│ └── ontocast/ ← Phase 0.1에서 추가 │ └── ontocast/ ← Phase 0.1에서 추가
├── platform/ ← 우리가 작성하는 코드 ├── ont_platform/ ← 우리가 작성하는 코드 (※ Python 내장 `platform` 모듈과 이름 충돌을 피하기 위해 `platform/`에서 변경됨)
│ ├── api/ ← Phase 0.4 (FastAPI) │ ├── api/ ← Phase 0.4 (FastAPI)
│ ├── core/ │ ├── core/
│ │ ├── extractors/ ← Phase 1 (Trafilatura) │ │ ├── extractors/ ← Phase 1 (Trafilatura)

View File

@@ -6,16 +6,19 @@
| # | Acceptance Gate 항목 | 상태 | 검증 방법 | | # | Acceptance Gate 항목 | 상태 | 검증 방법 |
|---|---|---|---| |---|---|---|---|
| 1 | 단일 PDF/JSON 입력 → ontology TTL + facts TTL이 filesystem에 생성됨 | ⚠️ **코드 준비 완료, 실행 검증 보류** | `tests/e2e/test_phase0_full_pipeline.py`가 검증하나 LLM_API_KEY/Python 환경 필요 | | 1 | 단일 PDF/JSON 입력 → ontology TTL + facts TTL이 filesystem에 생성됨 | ⚠️ **e2e 검증 대기** (로컬 LLM/API 키 필요) | `tests/e2e/test_phase0_full_pipeline.py` |
| 2 | `/health`, `/info`, `/process` (FastAPI) 정상 동작 | ✅ **코드 작성 + 통합 테스트 통과 예상** | `tests/integration/test_api_smoke.py` 11개 케이스 | | 2 | `/health`, `/info`, `/process` (FastAPI) 정상 동작 | ✅ **통합 테스트 10/10 통과** (2026-05-14) | `tests/integration/test_api_smoke.py` |
| 3 | BudgetTracker가 LLM call/triple count를 정확히 기록 | ⚠️ **코드 준비 완료, 실 LLM 호출 검증 보류** | 통합 테스트는 mock 검증, e2e 테스트가 실제 검증 | | 3 | BudgetTracker가 LLM call/triple count를 정확히 기록 | ⚠️ **e2e 검증 대기** (mock 검증은 통합 테스트로 통과) | e2e 테스트가 실제 검증 |
| 4 | LangGraph 워크플로우 (CONVERT→CHUNK→...→SERIALIZE) 전 노드 traceable | ✅ **OntoCast 원본 워크플로우 무수정 채택** | `vendored/ontocast/ontocast/stategraph/` 그대로 사용 | | 4 | LangGraph 워크플로우 (CONVERT→CHUNK→...→SERIALIZE) 전 노드 traceable | ✅ **OntoCast 원본 워크플로우 무수정 채택** | `vendored/ontocast/ontocast/stategraph/` 그대로 사용 |
⚠️ **현재 환경에서 자동 실행이 안 되는 이유**: 추가로 **단위 테스트 16/16 통과** (test_convert_document 7, test_platform_config 5, test_select_ontology 4).
1. 시스템에 Python 인터프리터가 설치되어 있지 않음 (`python.exe`가 Microsoft Store 별칭만 있음, `py` 없음)
2. LLM API 키가 환경변수에 없음
따라서 **다음 작업자(또는 운영 환경)에서 아래 절차를 한 번 실행하여 4개 체크박스를 모두 통과 처리해야 한다**. 코드는 준비 완료. **현재 진척 (2026-05-14)**:
- Python 3.13.13 환경 + `pip install -e ".[dev]"` 완료
- `pip install -e vendored/ontocast` 로 OntoCast 의존성 설치 완료
- 패키지 이름 충돌 수정: `platform/``ont_platform/` (Python 내장 `platform` 모듈과 충돌)
- 단위 + 통합 테스트 26/26 모두 통과
- **남은 작업**: e2e 테스트 (Acceptance Gate #1, #3) 실행 — 로컬 Ollama 또는 OpenAI 키 필요
## 다음 작업자가 실행할 검증 절차 ## 다음 작업자가 실행할 검증 절차
@@ -54,8 +57,20 @@ pytest tests/unit tests/integration -v
### 3) End-to-end 검증 (Acceptance Gate #1, #3, #4) ### 3) End-to-end 검증 (Acceptance Gate #1, #3, #4)
LLM 호출이 실제로 일어남. OpenAI는 비용 발생, Ollama는 로컬에서 무료.
```powershell ```powershell
# LLM 호출이 일어남. 실 비용 발생. # (A) Ollama 로컬 사용 (권장 — 비용 무료)
# 사전: Ollama 설치 후 `ollama pull qwen2.5`
$env:LLM_PROVIDER = "ollama"
$env:LLM_MODEL_NAME = "qwen2.5"
$env:LLM_BASE_URL = "http://localhost:11434"
pytest tests/e2e -m e2e -v
# (B) OpenAI 사용
$env:LLM_PROVIDER = "openai"
$env:LLM_MODEL_NAME = "gpt-4o-mini"
$env:LLM_API_KEY = "sk-..."
pytest tests/e2e -m e2e -v pytest tests/e2e -m e2e -v
``` ```
@@ -70,7 +85,7 @@ pytest tests/e2e -m e2e -v
```powershell ```powershell
# 서버 기동 # 서버 기동
uvicorn platform.api.main:app --reload uvicorn ont_platform.api.main:app --reload
# 다른 셸에서 # 다른 셸에서
curl http://localhost:8000/health curl http://localhost:8000/health
@@ -100,4 +115,5 @@ curl -X POST http://localhost:8000/process `
| 일자 | 검증자 | 결과 | | 일자 | 검증자 | 결과 |
|---|---|---| |---|---|---|
| 2026-05-13 | (코드 작성: ontology-platform agent) | 코드 준비 완료. 실 환경 검증 보류. | | 2026-05-13 | (코드 작성: ontology-platform agent) | 코드 준비 완료. 실 환경 검증 보류. |
| 2026-05-14 | lasta + Claude | **unit 16/16, integration 10/10 통과** (Gate #2 ✅). 패키지 이름 충돌 수정 (`platform``ont_platform`). e2e는 LLM 필요로 대기. |
| ____-__-__ | ________________ | __________________________________ | | ____-__-__ | ________________ | __________________________________ |

View File

@@ -35,7 +35,7 @@ from ontocast.toolbox import ToolBox # noqa: E402
import importlib import importlib
platform_config = importlib.import_module("platform.config") platform_config = importlib.import_module("ont_platform.config")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -67,7 +67,10 @@ async def initialize_app_context(
settings = settings or platform_config.load_settings() settings = settings or platform_config.load_settings()
ontocast_config = platform_config.build_ontocast_config(settings) ontocast_config = platform_config.build_ontocast_config(settings)
tools = ToolBox(ontocast_config) # ToolBox.__init__ 내부에서 LLMTool.create()가 `asyncio.run()`을 호출한다.
# FastAPI lifespan/테스트가 이미 async 컨텍스트면 이중 loop 충돌이 나므로,
# 별도 스레드에서 sync 생성자를 실행한다.
tools = await asyncio.to_thread(ToolBox, ontocast_config)
# OntoCast's ToolBox.initialize is async; do it here so a request doesn't # OntoCast's ToolBox.initialize is async; do it here so a request doesn't
# have to pay the cost. # have to pay the cost.
await tools.initialize() await tools.initialize()

View File

@@ -38,14 +38,14 @@ if str(_VENDORED_ONTOCAST) not in sys.path:
from ontocast.onto.enum import RenderMode # noqa: E402 from ontocast.onto.enum import RenderMode # noqa: E402
from ontocast.onto.state import AgentState # noqa: E402 from ontocast.onto.state import AgentState # noqa: E402
from platform.api.deps import ( # noqa: E402 from ont_platform.api.deps import ( # noqa: E402
AppContext, AppContext,
RunnableConfig, RunnableConfig,
get_app_context, get_app_context,
initialize_app_context, initialize_app_context,
) )
platform_config = importlib.import_module("platform.config") platform_config = importlib.import_module("ont_platform.config")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -381,7 +381,7 @@ app = create_app()
@click.option("--reload", is_flag=True, default=False) @click.option("--reload", is_flag=True, default=False)
def cli(host: str, port: int, reload: bool) -> None: # noqa: FBT001 def cli(host: str, port: int, reload: bool) -> None: # noqa: FBT001
"""Console entry point: ``ontology-platform`` (see pyproject.toml).""" """Console entry point: ``ontology-platform`` (see pyproject.toml)."""
uvicorn.run("platform.api.main:app", host=host, port=port, reload=reload) uvicorn.run("ont_platform.api.main:app", host=host, port=port, reload=reload)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -16,6 +16,7 @@ build a ``Config`` instance and pass it to ``ToolBox``.
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
import sys import sys
from enum import IntEnum from enum import IntEnum
from pathlib import Path from pathlib import Path
@@ -138,6 +139,31 @@ class PlatformSettings(BaseSettings):
return self return self
def _build_llm_config_lenient() -> LLMConfig:
"""env vars로부터 LLMConfig 생성. OntoCast의 OpenAIModel enum validation을 우회한다.
Why: LM Studio / vLLM / llama.cpp OpenAI-호환 로컬 서버는 임의의 model
identifier를 쓰며(: `deepseek-r1-distill-qwen-7b`), 이는 OntoCast의 정해진
enum(`gpt-4o`, `gpt-4o-mini`, ...) 들어가지 않는다. ChatOpenAI는 model을
문자열로 받으므로 enum 강제만 풀면 OntoCast 다른 코드 경로는 그대로 동작한다.
`LLMConfig.model_construct` Pydantic V2의 validation 우회 생성자다.
"""
provider_raw = (os.getenv("LLM_PROVIDER") or "openai").lower()
model_name = os.getenv("LLM_MODEL_NAME") or "gpt-4o-mini"
temperature_raw = os.getenv("LLM_TEMPERATURE") or "0.0"
base_url = os.getenv("LLM_BASE_URL") or None
api_key = os.getenv("LLM_API_KEY") or None
return LLMConfig.model_construct(
provider=provider_raw,
model_name=model_name,
temperature=float(temperature_raw),
base_url=base_url,
api_key=api_key,
)
def _empty_neo4j_config() -> Neo4jConfig: def _empty_neo4j_config() -> Neo4jConfig:
"""A Neo4jConfig with no URI/auth so ToolBox skips Neo4j initialization. """A Neo4jConfig with no URI/auth so ToolBox skips Neo4j initialization.
@@ -171,9 +197,18 @@ def build_ontocast_config(settings: PlatformSettings) -> OntoCastConfig:
- Neo4j and Fuseki are forcibly disabled regardless of NEO4J_*/FUSEKI_* - Neo4j and Fuseki are forcibly disabled regardless of NEO4J_*/FUSEKI_*
env vars in the shell. They will be wired in Phase 4. env vars in the shell. They will be wired in Phase 4.
""" """
# Start from defaults that pull in any LLM_*/CHUNK_*/AGG_* env vars # OntoCast의 OpenAIModel enum은 클라우드 모델만 허용한다. LM Studio 등
# via each section's own SettingsConfigDict. # 임의의 모델명을 쓰는 로컬 서버는 ToolConfig() 생성 단계에서 검증이 실패
tool_cfg = ToolConfig() # 하므로, ToolConfig를 만들 동안만 LLM_MODEL_NAME을 비우고 lenient 빌더로
# 교체한다. CHUNK_*/AGG_* 등 다른 섹션 env는 그대로 흘러가도록 유지한다.
saved_model = os.environ.pop("LLM_MODEL_NAME", None)
try:
tool_cfg = ToolConfig()
finally:
if saved_model is not None:
os.environ["LLM_MODEL_NAME"] = saved_model
tool_cfg.llm_config = _build_llm_config_lenient()
# Override paths from the platform settings. # Override paths from the platform settings.
tool_cfg.path_config = PathConfig( tool_cfg.path_config = PathConfig(

View File

@@ -113,16 +113,16 @@ dev = [
] ]
[project.scripts] [project.scripts]
ontology-platform = "platform.api.main:cli" ontology-platform = "ont_platform.api.main:cli"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["platform"] packages = ["ont_platform"]
# ─── Ruff (linter + formatter) ──────────────────────────────────────── # ─── Ruff (linter + formatter) ────────────────────────────────────────
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100
target-version = "py312" target-version = "py312"
src = ["platform", "tests"] src = ["ont_platform", "tests"]
extend-exclude = ["vendored"] # vendored OntoCast 등은 원본 유지 extend-exclude = ["vendored"] # vendored OntoCast 등은 원본 유지
[tool.ruff.lint] [tool.ruff.lint]

View File

@@ -1,12 +1,17 @@
"""E2E test configuration. """E2E test configuration.
These tests are SKIPPED by default. To run them set ``LLM_API_KEY`` and These tests are SKIPPED by default unless a valid LLM provider is configured.
``LLM_PROVIDER`` (and any model overrides) in the environment, then run: The repo's `.env` file is auto-loaded so the same settings used by the app
also drive the test run.
Provider별 통과 조건:
- openai : `LLM_API_KEY` 필요 (LM Studio 등 OpenAI-호환 로컬 서버는 더미 키도 OK)
- ollama : 별도 키 불필요 (로컬 데몬만 동작하면 됨)
To run:
pytest tests/e2e -m e2e pytest tests/e2e -m e2e
They exercise the full OntoCast workflow end-to-end — LLM calls included — LLM 호출이 실제로 일어남. OpenAI 클라우드는 비용 발생, 로컬 LLM은 무료.
and so they cost real money. Keep them out of CI default runs.
""" """
from __future__ import annotations from __future__ import annotations
@@ -22,18 +27,47 @@ for p in (REPO_ROOT, REPO_ROOT / "vendored" / "ontocast"):
if str(p) not in sys.path: if str(p) not in sys.path:
sys.path.insert(0, str(p)) sys.path.insert(0, str(p))
# Auto-load .env so e2e tests pick up the same LLM settings as the app.
_env_path = REPO_ROOT / ".env"
if _env_path.exists():
try:
from dotenv import load_dotenv
load_dotenv(_env_path, override=False)
except ImportError:
# python-dotenv가 없으면 직접 간단 파싱.
for line in _env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
os.environ.setdefault(key.strip(), value.strip())
def _llm_configured() -> bool:
"""Provider별로 e2e 실행 가능 여부 판단."""
provider = (os.environ.get("LLM_PROVIDER") or "openai").lower()
if provider == "ollama":
# Ollama는 별도 API 키 불필요.
return True
# openai 또는 OpenAI-호환 로컬 서버 — 더미 키라도 들어 있어야 OK.
return bool(os.environ.get("LLM_API_KEY"))
def pytest_collection_modifyitems( def pytest_collection_modifyitems(
config: pytest.Config, items: list[pytest.Item] config: pytest.Config, items: list[pytest.Item]
) -> None: ) -> None:
"""Skip all e2e tests when LLM_API_KEY is not configured.""" """Skip e2e tests unless the LLM provider is properly configured."""
if os.environ.get("LLM_API_KEY"): if _llm_configured():
return return
provider = (os.environ.get("LLM_PROVIDER") or "openai").lower()
skip_marker = pytest.mark.skip( skip_marker = pytest.mark.skip(
reason="E2E tests require LLM_API_KEY in environment." reason=(
f"E2E tests require LLM provider config. "
f"Provider={provider!r}, LLM_API_KEY={'set' if os.environ.get('LLM_API_KEY') else 'missing'}."
)
) )
for item in items: for item in items:
# Only apply to tests in this directory tree.
if "tests/e2e" in str(item.fspath).replace("\\", "/"): if "tests/e2e" in str(item.fspath).replace("\\", "/"):
item.add_marker(skip_marker) item.add_marker(skip_marker)

View File

@@ -26,9 +26,9 @@ REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path: if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT)) sys.path.insert(0, str(REPO_ROOT))
main_module = importlib.import_module("platform.api.main") main_module = importlib.import_module("ont_platform.api.main")
deps_module = importlib.import_module("platform.api.deps") deps_module = importlib.import_module("ont_platform.api.deps")
platform_config = importlib.import_module("platform.config") platform_config = importlib.import_module("ont_platform.config")
@pytest.mark.e2e @pytest.mark.e2e
@@ -40,7 +40,12 @@ async def test_full_pipeline_writes_ontology_and_facts(
# leak between runs. # leak between runs.
monkeypatch.setenv("ONTOCAST_WORKING_DIRECTORY", str(tmp_path / "work")) monkeypatch.setenv("ONTOCAST_WORKING_DIRECTORY", str(tmp_path / "work"))
# Honor whatever LLM provider the operator configured. # Honor whatever LLM provider the operator configured.
assert os.environ.get("LLM_API_KEY"), "LLM_API_KEY must be set for e2e" # OpenAI는 API 키 필수, Ollama는 로컬 데몬만 떠 있으면 됨.
provider = os.environ.get("LLM_PROVIDER", "openai").lower()
if provider == "openai":
assert os.environ.get("LLM_API_KEY"), (
"LLM_API_KEY must be set for e2e with OpenAI provider"
)
# Force a fresh AppContext so the new working_directory wins. # Force a fresh AppContext so the new working_directory wins.
deps_module.reset_app_context_for_testing() deps_module.reset_app_context_for_testing()
@@ -49,11 +54,28 @@ async def test_full_pipeline_writes_ontology_and_facts(
app = main_module.create_app() app = main_module.create_app()
# Tiny but ontology-rich payload. # OntoCast SemanticChunker가 HDBSCAN + UMAP을 쓰므로 최소 문장 수가
# 필요하다. 2문장짜리 toy 입력은 "k must be ≤ training points"로 실패한다.
# Acceptance Gate #1은 "단일 PDF/JSON → TTL 생성"이지 문장 수와 무관하므로,
# ontology-rich한 짧은 단락을 충분한 문장 수로 늘려준다.
payload = { payload = {
"text": ( "text": (
"Alice works at Acme Corporation in Berlin. " "Alice Carter works at Acme Corporation in Berlin. "
"Acme Corporation manufactures bicycles." "Acme Corporation is a manufacturing company founded in 1992. "
"Acme manufactures bicycles and electric scooters. "
"Bob Lee is the chief engineer at Acme Corporation. "
"He reports to Alice Carter, who heads the engineering division. "
"Acme's main factory is located in Berlin, Germany. "
"The company also operates a research center in Munich. "
"Carol Schmidt leads research at the Munich center. "
"She previously worked at Globex Industries in Hamburg. "
"Globex Industries is a competitor in the bicycle market. "
"Acme exports bicycles to France, Italy, and Spain. "
"The product line includes road bikes, mountain bikes, and city bikes. "
"Alice Carter graduated from the Technical University of Berlin. "
"Bob Lee holds a doctorate in mechanical engineering. "
"Acme employs around 450 people across its three sites. "
"The company reported annual revenue of 120 million euros last year."
), ),
"ontology_user_instruction": "Focus on person-organization-location relations.", "ontology_user_instruction": "Focus on person-organization-location relations.",
"facts_user_instruction": "Extract employment and manufacturing facts.", "facts_user_instruction": "Extract employment and manufacturing facts.",

View File

@@ -1,7 +1,7 @@
"""Shared fixtures for integration tests. """Shared fixtures for integration tests.
Sets up the Python path so `import platform.api.main` resolves the local Sets up the Python path so `import ont_platform.api.main` resolves the local
package (not the stdlib `platform` module) and exposes helpers that turn package and exposes helpers that turn
the FastAPI app into a controllable test harness. the FastAPI app into a controllable test harness.
""" """

View File

@@ -18,6 +18,7 @@ from __future__ import annotations
import importlib import importlib
import json import json
import sys import sys
from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any from typing import Any
@@ -33,8 +34,8 @@ VENDORED_ONTOCAST = REPO_ROOT / "vendored" / "ontocast"
if str(VENDORED_ONTOCAST) not in sys.path: if str(VENDORED_ONTOCAST) not in sys.path:
sys.path.insert(0, str(VENDORED_ONTOCAST)) sys.path.insert(0, str(VENDORED_ONTOCAST))
main_module = importlib.import_module("platform.api.main") main_module = importlib.import_module("ont_platform.api.main")
deps_module = importlib.import_module("platform.api.deps") deps_module = importlib.import_module("ont_platform.api.deps")
from ontocast.onto.enum import RenderMode # noqa: E402 from ontocast.onto.enum import RenderMode # noqa: E402
@@ -84,6 +85,11 @@ def _make_mock_context(workflow_chunks: list[dict[str, Any]]) -> SimpleNamespace
) )
@asynccontextmanager
async def _noop_lifespan(app): # noqa: ARG001
yield
def _client_with_context(ctx: SimpleNamespace) -> TestClient: def _client_with_context(ctx: SimpleNamespace) -> TestClient:
"""Return a TestClient whose ``get_app_context`` returns the given ctx. """Return a TestClient whose ``get_app_context`` returns the given ctx.
@@ -92,9 +98,8 @@ def _client_with_context(ctx: SimpleNamespace) -> TestClient:
surface in test output. surface in test output.
""" """
app = main_module.create_app() app = main_module.create_app()
app.router.lifespan_context = _noop_lifespan
app.dependency_overrides[deps_module.get_app_context] = lambda: ctx 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") return TestClient(app, raise_server_exceptions=True, backend="asyncio")

View File

@@ -23,7 +23,11 @@ VENDORED_ONTOCAST = REPO_ROOT / "vendored" / "ontocast"
if str(VENDORED_ONTOCAST) not in sys.path: if str(VENDORED_ONTOCAST) not in sys.path:
sys.path.insert(0, str(VENDORED_ONTOCAST)) sys.path.insert(0, str(VENDORED_ONTOCAST))
from ontocast.agent import convert_document as convert_document_module # noqa: E402 import sys
import ontocast.agent # __init__.py 실행으로 서브모듈이 sys.modules에 등록됨 # noqa: E402
convert_document_module = sys.modules["ontocast.agent.convert_document"]
from ontocast.onto.enum import Status # noqa: E402 from ontocast.onto.enum import Status # noqa: E402

View File

@@ -24,7 +24,7 @@ if str(PLATFORM_ROOT) not in sys.path:
# by relying on the package being on sys.path before site-packages.) # by relying on the package being on sys.path before site-packages.)
import importlib import importlib
platform_config = importlib.import_module("platform.config") platform_config = importlib.import_module("ont_platform.config")
def _clear_settings_env(monkeypatch: pytest.MonkeyPatch) -> None: def _clear_settings_env(monkeypatch: pytest.MonkeyPatch) -> None:

View File

@@ -29,7 +29,11 @@ if str(VENDORED_ONTOCAST) not in sys.path:
sys.path.insert(0, str(VENDORED_ONTOCAST)) sys.path.insert(0, str(VENDORED_ONTOCAST))
# Imports must come after sys.path tweak. # Imports must come after sys.path tweak.
from ontocast.agent import select_ontology as select_ontology_module # noqa: E402 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.enum import Status # noqa: E402
from ontocast.onto.null import NULL_ONTOLOGY # noqa: E402 from ontocast.onto.null import NULL_ONTOLOGY # noqa: E402

View File

@@ -28,6 +28,20 @@
- **회귀 테스트**: `tests/unit/test_convert_document.py` (7 케이스 — 단일 PDF / 단일 JSON / 다중 PDF / 다중 JSON first-wins / 미지원 확장자 / 빈 입력 / 혼합 PDF+JSON) - **회귀 테스트**: `tests/unit/test_convert_document.py` (7 케이스 — 단일 PDF / 단일 JSON / 다중 PDF / 다중 JSON first-wins / 미지원 확장자 / 빈 입력 / 혼합 PDF+JSON)
- **근거**: docs/통합설계서.md §5 Phase 0, OntoCast 분석 §13.1 / §21.1 (#4) - **근거**: docs/통합설계서.md §5 Phase 0, OntoCast 분석 §13.1 / §21.1 (#4)
### 2026-05-14 — node_factories.py: BudgetTracker가 LLM 호출을 기록하지 못하던 버그 수정
- **파일**: `ontocast/stategraph/node_factories.py`
- **문제**: `make_render_ontology_node`(line 91)와 `make_render_facts_node`(line 256)에서 병렬 unit 처리 시 `base_state = state.model_copy(deep=True)`로 root state를 deep-copy 한 뒤 `budget_tracker=base_state.budget_tracker`를 child state(`UnitOntologyState`/`UnitFactsState`)에 전달했다. 그 결과 LLM이 add_usage()를 호출해도 *복사본* 인스턴스만 갱신되고 root state.budget_tracker는 영원히 0인 채로 남아 BudgetTracker가 LLM 호출 수, chars sent/received를 전혀 기록하지 못했다. e2e 실행 시 LM Studio 로그에 LLM 호출이 분명히 들어왔는데(token count 검증됨) workflow_state["budget_tracker"].calls_count == 0으로 응답되는 증상으로 확인됨.
- **수정**: 두 곳 모두 `base_state.budget_tracker``state.budget_tracker`로 변경. `base_state = state.model_copy(deep=True)` 라인 자체가 budget_tracker 추출에만 쓰였으므로 함께 제거. Python int `+= 1` 연산은 GIL 하에서 원자적이라 병렬 process_unit 간 race condition은 무시 가능한 수준.
- **회귀 테스트**: e2e (`tests/e2e/test_phase0_full_pipeline.py`)에서 `budget.calls_count > 0` 검증.
- **근거**: docs/phases/PHASE0_ACCEPTANCE_GATE.md (Gate #3 통과를 위한 차단 이슈).
### 2026-05-14 — render_ontology.py: Bootstrap KeyError 수정
- **파일**: `ontocast/agent/render_ontology.py`
- **문제**: `vendored/ontocast/ontocast/prompt/render_ontology.py:44``general_ontology_instruction = f"""...{prefix_instruction}..."""`이 f-string이라 모듈 로드 시점에 `prefix_instruction` 변수가 즉시 치환됨. 그 결과 최종 문자열에는 `prefix_instruction` 자체가 가진 `{ontology_prefix}` placeholder만 남는다. `render_ontology_update`는 호출 시 `ontology_prefix=current.prefix`도 함께 넘기지만 `render_ontology_fresh`는 누락 → Bootstrap 단계에서 `KeyError: 'ontology_prefix'`.
- **수정**: `render_ontology_fresh()``.format()` 호출에 `ontology_prefix=""`를 추가. Bootstrap 시점에는 prefix가 아직 LLM에 의해 정의되지 않으므로 빈 문자열이 의미적으로 올바름.
- **회귀 테스트**: e2e (`tests/e2e/test_phase0_full_pipeline.py`)에서 검증.
- **근거**: docs/phases/PHASE0_ACCEPTANCE_GATE.md (Gate #1 통과를 위한 차단 이슈).
### 2026-05-13 — Phase 0.2: select_ontology.py None-index 버그 수정 ### 2026-05-13 — Phase 0.2: select_ontology.py None-index 버그 수정
- **파일**: `ontocast/agent/select_ontology.py` - **파일**: `ontocast/agent/select_ontology.py`
- **문제**: dynamic Pydantic 모델은 `answer_index ∈ [1, num_ontologies + 1]`을 강제하지만 코드는 `answer_index == 0`을 "None"으로 처리. 0은 Pydantic 검증을 통과할 수 없어 dead code였으며, LLM이 None을 선택할 때마다(`num_ontologies + 1` 반환) defensive branch로 빠져 WARNING 로그가 찍혔다. - **문제**: dynamic Pydantic 모델은 `answer_index ∈ [1, num_ontologies + 1]`을 강제하지만 코드는 `answer_index == 0`을 "None"으로 처리. 0은 Pydantic 검증을 통과할 수 없어 dead code였으며, LLM이 None을 선택할 때마다(`num_ontologies + 1` 반환) defensive branch로 빠져 WARNING 로그가 찍혔다.

View File

@@ -5,6 +5,11 @@ human-readable formats, making the ontological knowledge more accessible and
understandable. understandable.
The agent decides between generating bare Turtle for fresh ontologies and SPARQL operations for updates. The agent decides between generating bare Turtle for fresh ontologies and SPARQL operations for updates.
# MODIFIED 2026-05-14 (ontology_platform): render_ontology_fresh()의 .format()
# 호출에 ontology_prefix 키가 누락되어 Bootstrap 단계에서 KeyError 발생.
# general_ontology_instruction은 f-string 모듈 로드 시점에 prefix_instruction이
# 치환되며 그 안의 {ontology_prefix} 플레이스홀더만 남는다. Fresh 시점에는
# prefix가 아직 LLM에 의해 정해지지 않으므로 빈 문자열을 전달한다.
""" """
import logging import logging
@@ -109,8 +114,13 @@ async def render_ontology_fresh(
output_instruction = output_instruction_ttl output_instruction = output_instruction_ttl
ontology_ttl = "" ontology_ttl = ""
improvement_instruction_str = "" improvement_instruction_str = ""
# MODIFIED 2026-05-14 (ontology_platform): Bootstrap에서는 prefix가 아직
# 정해지지 않았으므로 ontology_prefix를 빈 문자열로 채워 KeyError를 피한다.
# prefix_instruction= 인자는 f-string으로 이미 박혔으므로 무시되지만 호환을
# 위해 그대로 둔다.
general_ontology_instruction_str = general_ontology_instruction.format( general_ontology_instruction_str = general_ontology_instruction.format(
prefix_instruction=prefix_instruction_fresh prefix_instruction=prefix_instruction_fresh,
ontology_prefix="",
) )
text_chapter = text_template.format(text=state.content_unit.text) text_chapter = text_template.format(text=state.content_unit.text)

View File

@@ -1,3 +1,7 @@
# MODIFIED 2026-05-14 (ontology_platform): make_render_ontology_node와
# make_render_facts_node에서 `state.model_copy(deep=True)`로 budget_tracker가
# deep-copy되어 root state의 BudgetTracker가 0인 채로 남던 버그 수정.
# 자세한 내역은 vendored/ontocast/VENDORED_MODIFICATIONS.md 참고.
import asyncio import asyncio
import logging import logging
@@ -88,12 +92,19 @@ def make_render_ontology_node(tools: ToolBox):
async def process_unit(unit_index: int) -> tuple[int, UnitOntologyState]: async def process_unit(unit_index: int) -> tuple[int, UnitOntologyState]:
async with semaphore: async with semaphore:
base_state = state.model_copy(deep=True) # MODIFIED 2026-05-14 (ontology_platform): 원래 코드는
# `state.model_copy(deep=True)`로 budget_tracker를 deep-copy해
# child state에 넘겼다. 그 결과 LLM이 add_usage()를 호출해도
# *복사본* 인스턴스만 갱신되고 root state.budget_tracker는
# 영원히 0인 채로 남아 BudgetTracker가 LLM 호출을 기록하지
# 못했다. 원본 인스턴스를 공유해 add_usage가 root state까지
# 직접 반영되도록 수정. (Python int += 1은 GIL 하에서 원자적이라
# 병렬 process_unit 간 race-condition 위험은 무시 가능)
ontology_state = UnitOntologyState( ontology_state = UnitOntologyState(
content_unit=state.content_units[unit_index], content_unit=state.content_units[unit_index],
ontology_snapshot=state.current_ontology, ontology_snapshot=state.current_ontology,
ontology_user_instruction=state.ontology_user_instruction, ontology_user_instruction=state.ontology_user_instruction,
budget_tracker=base_state.budget_tracker, budget_tracker=state.budget_tracker,
max_visits_per_node=tools.config.server.max_visits_per_node, max_visits_per_node=tools.config.server.max_visits_per_node,
current_domain=state.current_domain, current_domain=state.current_domain,
ontology_max_triples=tools.config.server.ontology_max_triples, ontology_max_triples=tools.config.server.ontology_max_triples,
@@ -253,12 +264,14 @@ def make_render_facts_node(tools: ToolBox):
async def process_unit(unit_index: int) -> tuple[int, UnitFactsState]: async def process_unit(unit_index: int) -> tuple[int, UnitFactsState]:
async with semaphore: async with semaphore:
base_state = state.model_copy(deep=True) # MODIFIED 2026-05-14 (ontology_platform): same fix as
# make_render_ontology_node — share the root budget_tracker
# instead of a deep-copied detached instance.
facts_state = UnitFactsState( facts_state = UnitFactsState(
content_unit=state.content_units[unit_index], content_unit=state.content_units[unit_index],
ontology_snapshot=state.current_ontology, ontology_snapshot=state.current_ontology,
facts_user_instruction=state.facts_user_instruction, facts_user_instruction=state.facts_user_instruction,
budget_tracker=base_state.budget_tracker, budget_tracker=state.budget_tracker,
max_visits_per_node=tools.config.server.max_visits_per_node, max_visits_per_node=tools.config.server.max_visits_per_node,
) )
result = await facts_loop(facts_state, atomic_tools) result = await facts_loop(facts_state, atomic_tools)