474 lines
18 KiB
Python
474 lines
18 KiB
Python
"""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,
|
||
)
|
||
from ont_platform.api.product_backend import include_product_backend # noqa: E402
|
||
|
||
platform_config = importlib.import_module("ont_platform.config")
|
||
|
||
logger = logging.getLogger(__name__)
|
||
_startup_error: str | None = None
|
||
|
||
|
||
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"
|
||
|
||
|
||
def _include_phase_routers(app: FastAPI) -> None:
|
||
"""Attach routers whose dependencies are enabled for the configured phase."""
|
||
settings = platform_config.load_settings()
|
||
enabled_routes: list[str] = ["product-backend"]
|
||
|
||
if settings.phase >= platform_config.Phase.TRAFILATURA:
|
||
try:
|
||
from ont_platform.api.routes import get_extraction_router
|
||
except ImportError as exc:
|
||
raise RuntimeError(
|
||
"Phase 1 route loading requires the Phase 1 extraction dependencies. "
|
||
"Install the Phase 1 dependency set or run with PHASE=0."
|
||
) from exc
|
||
app.include_router(get_extraction_router())
|
||
enabled_routes.append("extraction")
|
||
|
||
if settings.phase >= platform_config.Phase.CANDIDATE_REVIEW:
|
||
from ont_platform.api.routes import get_review_router
|
||
|
||
app.include_router(get_review_router())
|
||
enabled_routes.append("review")
|
||
|
||
if settings.phase >= platform_config.Phase.CRAWL4AI:
|
||
from ont_platform.api.routes import get_crawl_router
|
||
|
||
app.include_router(get_crawl_router())
|
||
enabled_routes.append("crawl")
|
||
|
||
if settings.phase >= platform_config.Phase.NEO4J_GRAPHRAG:
|
||
from ont_platform.api.routes import get_graph_router
|
||
|
||
app.include_router(get_graph_router())
|
||
enabled_routes.append("graph")
|
||
|
||
if settings.phase >= platform_config.Phase.MULTI_AGENT:
|
||
from ont_platform.api.routes import get_maintenance_router
|
||
|
||
app.include_router(get_maintenance_router())
|
||
enabled_routes.append("maintenance")
|
||
|
||
app.state.enabled_phase_routes = enabled_routes
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||
"""FastAPI lifespan: build ToolBox + workflow once on startup."""
|
||
global _startup_error
|
||
settings = platform_config.load_settings()
|
||
try:
|
||
await initialize_app_context(settings)
|
||
_startup_error = None
|
||
except Exception as exc: # noqa: BLE001
|
||
_startup_error = str(exc)
|
||
logger.exception("App context initialization failed; product backend will remain available")
|
||
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,
|
||
)
|
||
include_product_backend(app)
|
||
|
||
# ─── /health ──────────────────────────────────────────────────────
|
||
@app.get("/health", tags=["meta"])
|
||
async def health(request: Request) -> JSONResponse:
|
||
"""Liveness check for the HTTP service and optional LLM readiness."""
|
||
settings = platform_config.load_settings()
|
||
if _startup_error:
|
||
return JSONResponse(
|
||
status_code=503,
|
||
content={
|
||
"status": "degraded",
|
||
"error": _startup_error,
|
||
"platform_version": PLATFORM_VERSION,
|
||
"ontocast_version": ONTOCAST_VERSION,
|
||
"phase": int(settings.phase),
|
||
"storage_backend": settings.storage_backend,
|
||
},
|
||
)
|
||
|
||
ctx = _request_app_context(request)
|
||
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(request: Request) -> JSONResponse:
|
||
"""Service-level capabilities (mirrors OntoCast /info semantics)."""
|
||
settings = platform_config.load_settings()
|
||
phase = int(settings.phase)
|
||
storage_backend = settings.storage_backend
|
||
if not _startup_error:
|
||
ctx = _request_app_context(request)
|
||
phase = int(ctx.settings.phase)
|
||
storage_backend = ctx.settings.storage_backend
|
||
|
||
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": phase,
|
||
"storage_backend": storage_backend,
|
||
"startup_error": _startup_error,
|
||
},
|
||
)
|
||
|
||
# ─── /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,
|
||
},
|
||
},
|
||
)
|
||
|
||
_include_phase_routers(app)
|
||
|
||
return app
|
||
|
||
|
||
def _request_app_context(request: Request) -> AppContext:
|
||
override = request.app.dependency_overrides.get(get_app_context)
|
||
if override is not None:
|
||
return override()
|
||
return get_app_context()
|
||
|
||
|
||
# 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()
|