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:
0
ontology_platform/ont_platform/api/__init__.py
Normal file
0
ontology_platform/ont_platform/api/__init__.py
Normal file
119
ontology_platform/ont_platform/api/deps.py
Normal file
119
ontology_platform/ont_platform/api/deps.py
Normal 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",
|
||||
]
|
||||
388
ontology_platform/ont_platform/api/main.py
Normal file
388
ontology_platform/ont_platform/api/main.py
Normal 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()
|
||||
Reference in New Issue
Block a user