"""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" 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] = [] 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.""" 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, }, }, ) _include_phase_routers(app) 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()