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

@@ -0,0 +1,119 @@
"""FastAPI dependencies and app lifespan.
Owns the ToolBox singleton, the compiled LangGraph workflow, and the
recursion limit. Construction happens once at app startup; teardown is a
no-op because OntoCast tools don't currently expose a close hook.
Why a module-level holder instead of `app.state`:
The /process route is async and may be entered concurrently. A simple
dict on `app.state` works, but pulling the ToolBox through a typed
dependency function makes the contract explicit and gives every route
the same view of "what tools and what workflow are available right now".
"""
from __future__ import annotations
import asyncio
import logging
import sys
from dataclasses import dataclass
from pathlib import Path
# Make vendored OntoCast importable. (Same trick as platform/config.py.)
_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))
from langchain_core.runnables import RunnableConfig # noqa: E402
from langgraph.graph.state import CompiledStateGraph # noqa: E402
from ontocast.cli.serve import calculate_recursion_limit # noqa: E402
from ontocast.config import ServerConfig # noqa: E402
from ontocast.stategraph import create_agent_graph # noqa: E402
from ontocast.toolbox import ToolBox # noqa: E402
import importlib
platform_config = importlib.import_module("ont_platform.config")
logger = logging.getLogger(__name__)
@dataclass
class AppContext:
"""Immutable holder for objects that live as long as the FastAPI app."""
settings: "platform_config.PlatformSettings"
tools: ToolBox
workflow: CompiledStateGraph
server_config: ServerConfig
recursion_limit: int
_context: AppContext | None = None
async def initialize_app_context(
settings: "platform_config.PlatformSettings | None" = None,
*,
head_chunks: int | None = None,
) -> AppContext:
"""Build ToolBox + workflow once. Idempotent on repeat calls."""
global _context
if _context is not None:
return _context
settings = settings or platform_config.load_settings()
ontocast_config = platform_config.build_ontocast_config(settings)
# 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
# have to pay the cost.
await tools.initialize()
workflow = create_agent_graph(tools)
server_config = ontocast_config.server
recursion_limit = calculate_recursion_limit(head_chunks, server_config)
_context = AppContext(
settings=settings,
tools=tools,
workflow=workflow,
server_config=server_config,
recursion_limit=recursion_limit,
)
logger.info(
"App context initialized "
f"(phase={int(settings.phase)}, backend={settings.storage_backend}, "
f"working_dir={settings.working_directory})"
)
return _context
def get_app_context() -> AppContext:
"""FastAPI dependency. Raises clearly if the lifespan never ran."""
if _context is None:
raise RuntimeError(
"AppContext not initialized — the app lifespan must run "
"`initialize_app_context()` before serving requests."
)
return _context
def reset_app_context_for_testing() -> None:
"""Test-only: drop the cached context so each test can rebuild it."""
global _context
_context = None
__all__ = [
"AppContext",
"RunnableConfig",
"get_app_context",
"initialize_app_context",
"reset_app_context_for_testing",
]

View File

@@ -0,0 +1,388 @@
"""FastAPI application entry point — replaces OntoCast's Robyn server.
Endpoint semantics mirror the original Robyn server (OntoCast 분석 §13.1§13.4)
with three deliberate differences:
1. `/flush` requires a confirmation token to prevent accidental destruction of
the triple store. OntoCast 분석 §21.1 (#6) flagged the unauth'd /flush as a
risk; we honor that.
2. `/process` only accepts ``application/json`` and ``multipart/form-data``,
matching the Robyn version, but error responses now use FastAPI's normal
status-code semantics rather than always-200-with-error-body.
3. The app no longer hard-codes ``version="0.1.1"``. The version is read from
the OntoCast vendored package so /health and /info stay in sync with the
vendored copy (OntoCast 분석 §21.1 #2).
"""
from __future__ import annotations
import importlib
import importlib.metadata as importlib_metadata
import logging
import sys
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Annotated, Any, AsyncIterator
import click
import uvicorn
from fastapi import Depends, FastAPI, File, Form, HTTPException, Query, Request, UploadFile
from fastapi.responses import JSONResponse
# Vendored OntoCast on sys.path before any ontocast import.
_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))
from ontocast.onto.enum import RenderMode # noqa: E402
from ontocast.onto.state import AgentState # noqa: E402
from ont_platform.api.deps import ( # noqa: E402
AppContext,
RunnableConfig,
get_app_context,
initialize_app_context,
)
platform_config = importlib.import_module("ont_platform.config")
logger = logging.getLogger(__name__)
def _resolve_ontocast_version() -> str:
"""Best-effort lookup of the vendored OntoCast version.
Reading the vendored ``pyproject.toml`` directly avoids requiring the
package to be installed into the active environment.
"""
pyproject = _VENDORED_ONTOCAST / "pyproject.toml"
try:
text = pyproject.read_text(encoding="utf-8")
except OSError:
return "unknown"
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("version") and "=" in stripped:
_, _, value = stripped.partition("=")
return value.strip().strip('"').strip("'")
# Fall back to package metadata if it happens to be installed.
try:
return importlib_metadata.version("ontocast")
except importlib_metadata.PackageNotFoundError:
return "unknown"
ONTOCAST_VERSION = _resolve_ontocast_version()
PLATFORM_VERSION = "0.0.1"
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""FastAPI lifespan: build ToolBox + workflow once on startup."""
settings = platform_config.load_settings()
await initialize_app_context(settings)
try:
yield
finally:
# No explicit teardown is needed today. When Phase 2/4 adds DB/HTTP
# pools they'll close here.
pass
def create_app() -> FastAPI:
app = FastAPI(
title="Ontology Platform",
description=(
"Universal ontology construction platform. Phase 0 wraps the "
"OntoCast core engine; later phases add Trafilatura, Crawl4AI, "
"Guardrails, and Neo4j GraphRAG. See docs/통합설계서.md."
),
version=PLATFORM_VERSION,
lifespan=lifespan,
)
# ─── /health ──────────────────────────────────────────────────────
@app.get("/health", tags=["meta"])
async def health(ctx: Annotated[AppContext, Depends(get_app_context)]) -> JSONResponse:
"""Liveness check. 503 if the LLM isn't wired."""
if ctx.tools.llm is None:
return JSONResponse(
status_code=503,
content={"status": "unhealthy", "error": "LLM not initialized"},
)
return JSONResponse(
status_code=200,
content={
"status": "healthy",
"platform_version": PLATFORM_VERSION,
"ontocast_version": ONTOCAST_VERSION,
"llm_provider": ctx.tools.llm_provider,
"phase": int(ctx.settings.phase),
"storage_backend": ctx.settings.storage_backend,
},
)
# ─── /info ────────────────────────────────────────────────────────
@app.get("/info", tags=["meta"])
async def info(ctx: Annotated[AppContext, Depends(get_app_context)]) -> JSONResponse:
"""Service-level capabilities (mirrors OntoCast /info semantics)."""
return JSONResponse(
status_code=200,
content={
"name": "ontology-platform",
"platform_version": PLATFORM_VERSION,
"ontocast_version": ONTOCAST_VERSION,
"description": (
"Universal ontology construction platform built on the "
"OntoCast agentic core."
),
"capabilities": ["text-to-triples", "ontology-extraction"],
"input_types": ["text", "json", "pdf", "markdown"],
"output_types": ["turtle", "json"],
"phase": int(ctx.settings.phase),
"storage_backend": ctx.settings.storage_backend,
},
)
# ─── /flush ───────────────────────────────────────────────────────
@app.post("/flush", tags=["admin"])
async def flush(
ctx: Annotated[AppContext, Depends(get_app_context)],
confirm: Annotated[
str | None,
Query(
description=(
"Must equal 'YES-I-WANT-TO-DELETE-EVERYTHING'. Guards "
"against accidental triple-store wipe (OntoCast 분석 §21.1 #6)."
),
),
] = None,
dataset: Annotated[
str | None,
Query(description="Fuseki only — specific dataset to clean."),
] = None,
) -> JSONResponse:
if confirm != "YES-I-WANT-TO-DELETE-EVERYTHING":
raise HTTPException(
status_code=400,
detail=(
"/flush requires confirm=YES-I-WANT-TO-DELETE-EVERYTHING "
"to guard against accidental data loss."
),
)
if ctx.tools.triple_store_manager is None:
raise HTTPException(
status_code=400,
detail="No triple store manager configured.",
)
try:
await ctx.tools.triple_store_manager.clean(dataset=dataset)
except Exception as exc: # noqa: BLE001
logger.exception("Error flushing triple store")
return JSONResponse(
status_code=500,
content={
"status": "error",
"error": str(exc),
"error_type": type(exc).__name__,
},
)
return JSONResponse(
status_code=200,
content={
"status": "success",
"message": (
f"Triple store flushed (dataset={dataset!r})"
if dataset
else "Triple store flushed (all datasets)"
),
},
)
# ─── /process ─────────────────────────────────────────────────────
@app.post("/process", tags=["pipeline"])
async def process(
request: Request,
ctx: Annotated[AppContext, Depends(get_app_context)],
# Query params — these mirror the Robyn semantics.
dataset: Annotated[str | None, Query()] = None,
render_mode: Annotated[str | None, Query()] = None,
ontology_user_instruction: Annotated[str, Query()] = "",
facts_user_instruction: Annotated[str, Query()] = "",
# Multipart fields — optional; when used, JSON body is rejected.
file: Annotated[UploadFile | None, File()] = None,
form_ontology_user_instruction: Annotated[str | None, Form(alias="ontology_user_instruction")] = None,
form_facts_user_instruction: Annotated[str | None, Form(alias="facts_user_instruction")] = None,
) -> JSONResponse:
"""Run the full OntoCast workflow over a single document.
Accepts either ``application/json`` (body = the JSON envelope OntoCast
already understands) or ``multipart/form-data`` (one ``file`` field).
Returns the produced ontology + facts Turtle, plus pipeline metadata
and the budget tracker snapshot.
"""
content_type = (request.headers.get("content-type") or "").lower()
files: dict[str, bytes]
if content_type.startswith("application/json"):
if file is not None:
raise HTTPException(
status_code=400,
detail="Use either JSON body or multipart, not both.",
)
body = await request.body()
if not body:
raise HTTPException(status_code=400, detail="Empty JSON body.")
files = {"input.json": body}
elif content_type.startswith("multipart/form-data"):
if file is None:
raise HTTPException(
status_code=400,
detail="multipart/form-data requires a 'file' field.",
)
filename = file.filename or "upload.bin"
content = await file.read()
files = {filename: content}
# Form fields override query-string instructions, matching the
# original Robyn precedence.
if form_ontology_user_instruction:
ontology_user_instruction = form_ontology_user_instruction
if form_facts_user_instruction:
facts_user_instruction = form_facts_user_instruction
else:
raise HTTPException(
status_code=415,
detail=(
"Unsupported content type. Use application/json or "
"multipart/form-data."
),
)
# Dataset switch (mostly a no-op on filesystem backend).
if dataset:
await ctx.tools.update_dataset(dataset)
# Parse render mode.
try:
render_mode_value = (
RenderMode(render_mode.lower().strip())
if render_mode
else ctx.server_config.render_mode
)
except ValueError:
logger.warning(
"Invalid render_mode %r; using default %r",
render_mode,
ctx.server_config.render_mode.value,
)
render_mode_value = ctx.server_config.render_mode
initial_state = AgentState(
files=files,
max_visits=ctx.server_config.max_visits_per_node,
render_mode=render_mode_value,
ontology_max_triples=ctx.server_config.ontology_max_triples,
dataset=dataset,
ontology_user_instruction=ontology_user_instruction,
facts_user_instruction=facts_user_instruction,
)
workflow_state: dict[str, Any] | None = None
try:
async for chunk in ctx.workflow.astream(
initial_state,
stream_mode="values",
config=RunnableConfig(recursion_limit=ctx.recursion_limit),
):
workflow_state = chunk
except Exception as exc: # noqa: BLE001
logger.exception("Workflow execution failed")
error_details = None
if workflow_state:
error_details = {
"stage": workflow_state.get("failure_stage", "unknown"),
"reason": workflow_state.get("failure_reason", "unknown"),
}
return JSONResponse(
status_code=500,
content={
"status": "error",
"error": str(exc),
"error_type": type(exc).__name__,
"error_details": error_details,
},
)
if workflow_state is None:
raise HTTPException(
status_code=500,
detail="Workflow did not return a valid state.",
)
# Budget snapshot.
budget_tracker_data: dict[str, Any] = {}
if workflow_state.get("budget_tracker"):
budget_tracker_data = workflow_state["budget_tracker"].model_dump()
total_units = len(workflow_state.get("content_units", []))
rm = workflow_state.get("render_mode")
render_facts_enabled = rm in (
RenderMode.FACTS,
RenderMode.ONTOLOGY_AND_FACTS,
getattr(RenderMode.FACTS, "value", None),
getattr(RenderMode.ONTOLOGY_AND_FACTS, "value", None),
)
processed_units = (
len(workflow_state.get("parallel_facts_units", []))
if render_facts_enabled
else total_units
)
chunks_remaining = max(total_units - processed_units, 0)
return JSONResponse(
status_code=200,
content={
"status": "success",
"data": {
"ontology": (
workflow_state["current_ontology"].graph.serialize(format="turtle")
if workflow_state.get("current_ontology")
else ""
),
"facts": (
workflow_state["aggregated_facts"].serialize(format="turtle")
if workflow_state.get("aggregated_facts")
else ""
),
},
"metadata": {
"status": str(workflow_state.get("status", "unknown")),
"chunks_processed": processed_units,
"chunks_remaining": chunks_remaining,
"budget": budget_tracker_data,
},
},
)
return app
# Top-level instance for `uvicorn platform.api.main:app`.
app = create_app()
@click.command()
@click.option("--host", default="0.0.0.0", show_default=True)
@click.option("--port", default=8000, show_default=True, type=int)
@click.option("--reload", is_flag=True, default=False)
def cli(host: str, port: int, reload: bool) -> None: # noqa: FBT001
"""Console entry point: ``ontology-platform`` (see pyproject.toml)."""
uvicorn.run("ont_platform.api.main:app", host=host, port=port, reload=reload)
if __name__ == "__main__":
cli()

View File

@@ -0,0 +1,257 @@
"""Platform-level configuration.
Thin layer over OntoCast's ``Config`` that adds:
1. A single explicit ``PHASE`` knob. Phase 0 forces filesystem-only storage
and ignores any Neo4j/Fuseki environment variables that may be present
(they belong to Phase 4 — see ``docs/통합설계서.md`` §5).
2. A platform-side ``working_directory`` default so the user does not have
to remember the legacy ``ONTOCAST_*`` env-var prefix.
3. Hook points for future phases (LLM budgets, robots policy, etc.).
The OntoCast vendored copy itself is not modified by this module — we only
build a ``Config`` instance and pass it to ``ToolBox``.
"""
from __future__ import annotations
import logging
import os
import sys
from enum import IntEnum
from pathlib import Path
from typing import Literal
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
# Make the vendored OntoCast package importable without users having to
# install it into the environment. This mirrors what tests/unit/test_*.py
# already do; centralizing it here keeps any module that imports our
# Settings consistent.
_REPO_ROOT = Path(__file__).resolve().parents[1]
_VENDORED_ONTOCAST = _REPO_ROOT / "vendored" / "ontocast"
if str(_VENDORED_ONTOCAST) not in sys.path:
sys.path.insert(0, str(_VENDORED_ONTOCAST))
# Late import — OntoCast must be on sys.path first.
from ontocast.config import ( # noqa: E402 (must follow sys.path tweak)
Config as OntoCastConfig,
)
from ontocast.config import ( # noqa: E402
FusekiConfig,
LLMConfig,
Neo4jConfig,
PathConfig,
ToolConfig,
)
logger = logging.getLogger(__name__)
class Phase(IntEnum):
"""Integration phase from the master design (docs/통합설계서.md §5).
Used as a gate: features whose phase exceeds ``Settings.phase`` are
disabled even if their environment variables are populated.
"""
BASE = 0 # OntoCast only, filesystem storage
TRAFILATURA = 1
CRAWL4AI = 2
GUARDRAILS = 3
NEO4J_GRAPHRAG = 4
MULTI_AGENT = 5
StorageBackend = Literal["filesystem", "fuseki", "neo4j"]
class PlatformSettings(BaseSettings):
"""Top-level platform settings, loaded from ``.env`` and the environment.
Only fields the platform itself needs are declared here. OntoCast-specific
settings (LLM, chunk, paths, ...) are loaded by OntoCast's own ``Config``
via its own ``env_prefix`` conventions and merged in ``build_ontocast_config``.
"""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
case_sensitive=False,
)
# ─── Phase gating ────────────────────────────────────────────────
phase: Phase = Field(
default=Phase.BASE,
description="Current integration phase (docs/통합설계서.md §5).",
)
storage_backend: StorageBackend = Field(
default="filesystem",
description=(
"Which storage backend to use. Phase 0 forces 'filesystem' "
"regardless of any Neo4j/Fuseki credentials in the environment."
),
)
# ─── Paths (mirror ONTOCAST_* but with platform defaults) ────────
working_directory: Path = Field(
default=Path("./data/working"),
description="Working directory for OntoCast filesystem store.",
validation_alias="ONTOCAST_WORKING_DIRECTORY",
)
ontology_directory: Path | None = Field(
default=None,
validation_alias="ONTOCAST_ONTOLOGY_DIRECTORY",
)
# ─── Server ──────────────────────────────────────────────────────
host: str = Field(default="0.0.0.0")
port: int = Field(default=8000)
log_level: str = Field(default="info")
# ─── Operational policy (used from Phase 2+) ─────────────────────
robots_policy: Literal["strict", "respect", "ignore"] = Field(
default="respect",
description="robots.txt 준수 정책. Phase 2 Crawl4AI 통합에서 사용.",
)
daily_llm_call_limit: int = Field(default=10_000)
daily_llm_token_limit: int = Field(default=10_000_000)
# ─── Phase 0 enforcement ─────────────────────────────────────────
@model_validator(mode="after")
def _enforce_phase_storage_consistency(self) -> "PlatformSettings":
"""Phase 0 forces filesystem; later phases may opt into other backends.
Anything other than 'filesystem' before Phase 4 is treated as a
configuration error — failing fast here is far easier to debug than
a half-wired Neo4j connection deep inside ToolBox.
"""
if self.phase < Phase.NEO4J_GRAPHRAG and self.storage_backend != "filesystem":
raise ValueError(
f"storage_backend={self.storage_backend!r} requires Phase 4+, "
f"but PHASE={int(self.phase)}. See docs/통합설계서.md §5."
)
# Ensure working directory exists for filesystem mode.
if self.storage_backend == "filesystem":
self.working_directory.mkdir(parents=True, exist_ok=True)
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:
"""A Neo4jConfig with no URI/auth so ToolBox skips Neo4j initialization.
ToolBox enables Neo4j only when both ``uri`` and ``auth`` are set
(vendored/ontocast/ontocast/toolbox.py around line 112). Returning an
instance with both ``None`` is the canonical way to disable the backend
even if NEO4J_* env vars happen to be populated in the shell.
"""
cfg = Neo4jConfig()
cfg.uri = None # type: ignore[assignment]
cfg.auth = None # type: ignore[assignment]
return cfg
def _empty_fuseki_config() -> FusekiConfig:
"""Same idea as ``_empty_neo4j_config`` for Fuseki."""
cfg = FusekiConfig()
cfg.uri = None # type: ignore[assignment]
cfg.auth = None # type: ignore[assignment]
return cfg
def build_ontocast_config(settings: PlatformSettings) -> OntoCastConfig:
"""Build an OntoCast ``Config`` from our ``PlatformSettings``.
Phase 0 behavior:
- LLM/Chunk/Aggregation/WebSearch sections come from their own env vars
via OntoCast's ``BaseSettings`` defaults. We do NOT touch them.
- Paths are overridden so the OntoCast working directory always matches
``PlatformSettings.working_directory``.
- Neo4j and Fuseki are forcibly disabled regardless of NEO4J_*/FUSEKI_*
env vars in the shell. They will be wired in Phase 4.
"""
# OntoCast의 OpenAIModel enum은 클라우드 모델만 허용한다. LM Studio 등
# 임의의 모델명을 쓰는 로컬 서버는 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.
tool_cfg.path_config = PathConfig(
working_directory=settings.working_directory,
ontology_directory=settings.ontology_directory,
)
# Phase 0: forcibly disable non-filesystem backends.
if settings.storage_backend == "filesystem":
tool_cfg.neo4j = _empty_neo4j_config()
tool_cfg.fuseki = _empty_fuseki_config()
# (No 'else' here yet — Phase 4 will add the fuseki/neo4j branches.)
cfg = OntoCastConfig(tool_config=tool_cfg)
# Validate LLM config eagerly so misconfiguration surfaces at startup,
# not on the first /process call.
try:
cfg.validate_llm_config()
except ValueError as exc:
# Re-raise with a hint about where to set the variable. OntoCast's
# error already mentions the env var name; we add the file location.
raise ValueError(f"{exc} (set it in `.env` or your shell environment)") from exc
return cfg
def load_settings() -> PlatformSettings:
"""Load `PlatformSettings` from the environment.
Separated from module-level instantiation so tests can swap env vars
via ``monkeypatch`` without import-time side effects.
"""
return PlatformSettings() # type: ignore[call-arg]
# Provider-grade LLM/embedding config helpers can be added in later phases.
# For Phase 0, OntoCast's own ``LLMConfig`` is sufficient.
__all__ = [
"Phase",
"PlatformSettings",
"StorageBackend",
"build_ontocast_config",
"load_settings",
"LLMConfig",
]