docs
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
"""Database dependencies for FastAPI."""
|
||||
|
||||
from typing import Generator
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from ont_platform.config import load_settings
|
||||
from ont_platform.storage.models import Base
|
||||
|
||||
# Initialize database engine (lazy singleton)
|
||||
_engine = None
|
||||
@@ -18,12 +20,14 @@ def get_db_engine():
|
||||
if _engine is None:
|
||||
settings = load_settings()
|
||||
database_url = settings.database_url
|
||||
_ensure_sqlite_parent(database_url)
|
||||
_engine = create_engine(
|
||||
database_url,
|
||||
connect_args={"timeout": 30} if "sqlite" in database_url else {},
|
||||
pool_pre_ping=True,
|
||||
echo=False,
|
||||
)
|
||||
Base.metadata.create_all(bind=_engine)
|
||||
return _engine
|
||||
|
||||
|
||||
@@ -46,4 +50,13 @@ def get_db() -> Generator[Session, None, None]:
|
||||
db.close()
|
||||
|
||||
|
||||
def _ensure_sqlite_parent(database_url: str) -> None:
|
||||
if not database_url.startswith("sqlite:///"):
|
||||
return
|
||||
db_path = database_url.removeprefix("sqlite:///")
|
||||
if db_path in {":memory:", ""}:
|
||||
return
|
||||
Path(db_path).expanduser().parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
__all__ = ["get_db", "get_db_engine", "get_session_factory"]
|
||||
|
||||
@@ -44,7 +44,6 @@ from ont_platform.api.deps import ( # noqa: E402
|
||||
get_app_context,
|
||||
initialize_app_context,
|
||||
)
|
||||
from ont_platform.api.routes import extraction_router # noqa: E402
|
||||
|
||||
platform_config = importlib.import_module("ont_platform.config")
|
||||
|
||||
@@ -78,6 +77,49 @@ 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."""
|
||||
@@ -369,8 +411,7 @@ def create_app() -> FastAPI:
|
||||
},
|
||||
)
|
||||
|
||||
# ─── Phase 0 routes ───────────────────────────────────────────────
|
||||
app.include_router(extraction_router)
|
||||
_include_phase_routers(app)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
"""API routes."""
|
||||
"""API route loaders.
|
||||
|
||||
from .extraction import router as extraction_router
|
||||
Future-phase routers stay behind lazy loader functions so importing the
|
||||
Phase 0 app does not require optional dependencies such as Trafilatura.
|
||||
"""
|
||||
|
||||
__all__ = ["extraction_router"]
|
||||
|
||||
def get_extraction_router():
|
||||
from .extraction import router
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def get_review_router():
|
||||
from .review import router
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def get_crawl_router():
|
||||
from .crawl import router
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def get_graph_router():
|
||||
from .graph import router
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def get_maintenance_router():
|
||||
from .maintenance import router
|
||||
|
||||
return router
|
||||
|
||||
|
||||
__all__ = [
|
||||
"get_crawl_router",
|
||||
"get_extraction_router",
|
||||
"get_graph_router",
|
||||
"get_maintenance_router",
|
||||
"get_review_router",
|
||||
]
|
||||
|
||||
91
ontology_platform/ont_platform/api/routes/crawl.py
Normal file
91
ontology_platform/ont_platform/api/routes/crawl.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Phase 3 crawl acquisition job routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ont_platform.api.db_deps import get_db
|
||||
from ont_platform.core.crawler import CachePolicy, CrawlProfile, RobotsPolicy
|
||||
from ont_platform.core.crawler.jobs import CrawlJobRequest, CrawlJobRunner, job_to_dict
|
||||
from ont_platform.storage.models import ExtractionJob
|
||||
|
||||
router = APIRouter(prefix="/api/v1/crawl", tags=["crawl"])
|
||||
|
||||
|
||||
class CrawlJobStartRequest(BaseModel):
|
||||
project_id: str = "default"
|
||||
url: str | None = None
|
||||
html: str | None = None
|
||||
profile: CrawlProfile = CrawlProfile.FAST_STATIC
|
||||
max_pages: int = Field(default=50, ge=1, le=50)
|
||||
max_depth: int = Field(default=1, ge=0, le=5)
|
||||
robots_policy: RobotsPolicy = RobotsPolicy.RESPECT
|
||||
cache_policy: CachePolicy = CachePolicy.ENABLED
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _require_input(self) -> CrawlJobStartRequest:
|
||||
if not self.url and not self.html:
|
||||
raise ValueError("url or html is required")
|
||||
return self
|
||||
|
||||
|
||||
@router.post("/jobs")
|
||||
async def start_crawl_job(
|
||||
request: Annotated[CrawlJobStartRequest, Body()],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> dict:
|
||||
runner = CrawlJobRunner(db)
|
||||
try:
|
||||
job = await runner.run(
|
||||
CrawlJobRequest(
|
||||
project_id=request.project_id,
|
||||
url=request.url,
|
||||
html=request.html,
|
||||
profile=request.profile,
|
||||
max_pages=request.max_pages,
|
||||
max_depth=request.max_depth,
|
||||
robots_policy=request.robots_policy,
|
||||
cache_policy=request.cache_policy,
|
||||
)
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
db.commit()
|
||||
raise HTTPException(status_code=500, detail=f"Crawl job failed: {exc}") from exc
|
||||
|
||||
db.commit()
|
||||
return {"status": "success", "job": job_to_dict(job)}
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}")
|
||||
def get_crawl_job(
|
||||
job_id: str,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> dict:
|
||||
job = db.get(ExtractionJob, job_id)
|
||||
if job is None or job.job_type != "crawl":
|
||||
raise HTTPException(status_code=404, detail=f"Crawl job not found: {job_id}")
|
||||
return {"job": job_to_dict(job)}
|
||||
|
||||
|
||||
@router.post("/jobs/{job_id}/cancel")
|
||||
def cancel_crawl_job(
|
||||
job_id: str,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> dict:
|
||||
job = db.get(ExtractionJob, job_id)
|
||||
if job is None or job.job_type != "crawl":
|
||||
raise HTTPException(status_code=404, detail=f"Crawl job not found: {job_id}")
|
||||
if job.status in {"completed", "failed", "canceled"}:
|
||||
return {"status": "noop", "job": job_to_dict(job)}
|
||||
job.status = "canceled"
|
||||
db.commit()
|
||||
return {"status": "success", "job": job_to_dict(job)}
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -1,71 +1,159 @@
|
||||
"""
|
||||
Phase 0 Extraction routes: Fast JSON Extraction MVP.
|
||||
"""Phase 1 URL/HTML ingestion routes."""
|
||||
|
||||
No database storage - just extract and return JSON candidates.
|
||||
Goal: 10-30 seconds per URL.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
import time
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Query
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from ont_platform.core.extractors.web_extractor import extract_web_content
|
||||
from ont_platform.core.extraction.lightweight_extractor import LightweightExtractor
|
||||
from ont_platform.core.extractors.web_extractor import extract_web_content
|
||||
from ont_platform.models.content_unit import PlatformContentUnit
|
||||
from ont_platform.storage.dedup_cache import get_default_dedup_cache
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["extraction"])
|
||||
router = APIRouter(tags=["extraction"])
|
||||
|
||||
|
||||
@router.post("/extract/url")
|
||||
async def extract_url(url: str):
|
||||
"""
|
||||
Extract candidates from URL (Phase 0 MVP).
|
||||
class UrlIngestRequest(BaseModel):
|
||||
"""URL/HTML request accepted by Phase 1 ingestion endpoints."""
|
||||
|
||||
Returns:
|
||||
{
|
||||
"url": "...",
|
||||
"title": "...",
|
||||
"entities": [...],
|
||||
"relations": [...],
|
||||
"extraction_time_sec": 0.5,
|
||||
"warnings": [...]
|
||||
}
|
||||
"""
|
||||
if not url:
|
||||
raise HTTPException(status_code=400, detail="url is required")
|
||||
url: str | None = None
|
||||
html: str | None = None
|
||||
project_id: str = "default"
|
||||
language: str | None = None
|
||||
skip_if_duplicate: bool = True
|
||||
ontology_user_instruction: str = ""
|
||||
facts_user_instruction: str = ""
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _require_url_or_html(self) -> UrlIngestRequest:
|
||||
if not self.url and not self.html:
|
||||
raise ValueError("url or html is required")
|
||||
return self
|
||||
|
||||
|
||||
class UrlIngestResponse(BaseModel):
|
||||
status: str
|
||||
url: str | None
|
||||
title: str | None
|
||||
author: str | None
|
||||
published_date: str | None
|
||||
language: str | None
|
||||
text_length: int
|
||||
source_document: dict = Field(default_factory=dict)
|
||||
evidence_spans: list[dict] = Field(default_factory=list)
|
||||
content_unit: dict = Field(default_factory=dict)
|
||||
dedup: dict = Field(default_factory=dict)
|
||||
entities: list = Field(default_factory=list)
|
||||
relations: list = Field(default_factory=list)
|
||||
extraction_time_sec: float
|
||||
entity_count: int
|
||||
relation_count: int
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _build_payload(
|
||||
payload: UrlIngestRequest | None,
|
||||
query_url: str | None,
|
||||
query_project_id: str,
|
||||
) -> UrlIngestRequest:
|
||||
if payload is None:
|
||||
try:
|
||||
return UrlIngestRequest(url=query_url, project_id=query_project_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
data = payload.model_dump()
|
||||
if query_url:
|
||||
data["url"] = query_url
|
||||
if query_project_id != "default" and payload.project_id == "default":
|
||||
data["project_id"] = query_project_id
|
||||
try:
|
||||
return UrlIngestRequest(**data)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/api/v1/extract/url", response_model=UrlIngestResponse)
|
||||
@router.post("/api/v1/process/url", response_model=UrlIngestResponse)
|
||||
@router.post("/process/url", response_model=UrlIngestResponse)
|
||||
async def extract_url(
|
||||
payload: Annotated[UrlIngestRequest | None, Body()] = None,
|
||||
url: Annotated[str | None, Query()] = None,
|
||||
project_id: Annotated[str, Query()] = "default",
|
||||
):
|
||||
"""Extract a SourceDocument-ready payload from URL or supplied HTML."""
|
||||
|
||||
request = _build_payload(payload, query_url=url, query_project_id=project_id)
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Step 1: Extract web content with Trafilatura
|
||||
extracted = extract_web_content(url=url)
|
||||
|
||||
# Step 2: Extract JSON candidates with lightweight extractor
|
||||
lightweight = LightweightExtractor(use_llm=False)
|
||||
candidates = lightweight.extract(
|
||||
text=extracted.text,
|
||||
project_id="default", # Phase 0: no projects yet
|
||||
document_id="temp",
|
||||
extracted = extract_web_content(
|
||||
html=request.html,
|
||||
url=request.url,
|
||||
lang=request.language,
|
||||
)
|
||||
source_document = extracted.to_source_document_dict(project_id=request.project_id)
|
||||
evidence_spans = [
|
||||
span.to_dict()
|
||||
for span in extracted.evidence_spans(
|
||||
project_id=request.project_id,
|
||||
document_id=source_document["id"],
|
||||
)
|
||||
]
|
||||
content_unit = PlatformContentUnit.from_extracted(extracted).to_dict()
|
||||
|
||||
dedup_result = get_default_dedup_cache().check_and_remember(
|
||||
project_id=request.project_id,
|
||||
document_id=source_document["id"],
|
||||
content_hash=extracted.content_hash,
|
||||
fingerprint=extracted.fingerprint,
|
||||
)
|
||||
|
||||
entities: list = []
|
||||
relations: list = []
|
||||
warnings: list[str] = []
|
||||
if dedup_result.is_duplicate and request.skip_if_duplicate:
|
||||
warnings.append("Duplicate source document skipped by fingerprint.")
|
||||
else:
|
||||
lightweight = LightweightExtractor(use_llm=False)
|
||||
candidates = lightweight.extract(
|
||||
text=extracted.text,
|
||||
project_id=request.project_id,
|
||||
document_id=source_document["id"],
|
||||
)
|
||||
entities = candidates.entities
|
||||
relations = candidates.relations
|
||||
warnings = candidates.warnings
|
||||
|
||||
extraction_time = time.time() - start_time
|
||||
|
||||
# Return just the JSON (entities/relations are already dicts)
|
||||
return {
|
||||
"url": url,
|
||||
"status": "success",
|
||||
"url": request.url,
|
||||
"title": extracted.title,
|
||||
"author": extracted.author,
|
||||
"published_date": extracted.publish_date,
|
||||
"language": extracted.language,
|
||||
"text_length": len(extracted.text),
|
||||
"entities": candidates.entities,
|
||||
"relations": candidates.relations,
|
||||
"source_document": source_document,
|
||||
"evidence_spans": evidence_spans,
|
||||
"content_unit": content_unit,
|
||||
"dedup": dedup_result.to_dict(),
|
||||
"entities": entities,
|
||||
"relations": relations,
|
||||
"extraction_time_sec": round(extraction_time, 2),
|
||||
"entity_count": len(candidates.entities),
|
||||
"relation_count": len(candidates.relations),
|
||||
"warnings": candidates.warnings,
|
||||
"entity_count": len(entities),
|
||||
"relation_count": len(relations),
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Extraction failed: {str(e)}")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Extraction failed: {exc}") from exc
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
79
ontology_platform/ont_platform/api/routes/graph.py
Normal file
79
ontology_platform/ont_platform/api/routes/graph.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Phase 5 projection and GraphRAG search routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ont_platform.api.db_deps import get_db
|
||||
from ont_platform.config import load_settings
|
||||
from ont_platform.core.graph.cypher_guard import ReadOnlyCypherGuard, UnsafeCypherError
|
||||
from ont_platform.core.graph.search import CandidateGraphSearchService
|
||||
from ont_platform.core.projection.rdf_to_neo4j import RDFToNeo4jProjector
|
||||
|
||||
router = APIRouter(prefix="/api/v1/graph", tags=["graph"])
|
||||
|
||||
|
||||
class ProjectionPreviewRequest(BaseModel):
|
||||
project_id: str = "default"
|
||||
triples: list[tuple[str, str, str]]
|
||||
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ReadOnlyCypherRequest(BaseModel):
|
||||
query: str
|
||||
limit: int | None = None
|
||||
|
||||
|
||||
@router.post("/projection/preview")
|
||||
async def preview_projection(request: Annotated[ProjectionPreviewRequest, Body()]) -> dict:
|
||||
projector = RDFToNeo4jProjector(project_id=request.project_id)
|
||||
result = await projector.preview_projection(
|
||||
request.triples,
|
||||
provenance=request.provenance,
|
||||
)
|
||||
return {"status": "success", "projection": result.to_dict()}
|
||||
|
||||
|
||||
@router.post("/cypher/read")
|
||||
def sanitize_read_only_cypher(request: Annotated[ReadOnlyCypherRequest, Body()]) -> dict:
|
||||
settings = load_settings()
|
||||
guard = ReadOnlyCypherGuard(max_limit=settings.text2cypher_result_limit)
|
||||
try:
|
||||
sanitized = guard.sanitize(request.query, limit=request.limit)
|
||||
except UnsafeCypherError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {
|
||||
"status": "success",
|
||||
"read_only": True,
|
||||
"query": sanitized.query,
|
||||
"limit": sanitized.limit,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
def search_graph(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
q: Annotated[str, Query(min_length=1)],
|
||||
project_id: Annotated[str, Query()] = "default",
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
) -> dict:
|
||||
settings = load_settings()
|
||||
effective_limit = min(limit, settings.graph_search_result_limit)
|
||||
results = CandidateGraphSearchService(db).search(
|
||||
project_id=project_id,
|
||||
query=q,
|
||||
limit=effective_limit,
|
||||
)
|
||||
return {
|
||||
"status": "success",
|
||||
"query": q,
|
||||
"result_count": len(results),
|
||||
"results": [result.to_dict() for result in results],
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
115
ontology_platform/ont_platform/api/routes/maintenance.py
Normal file
115
ontology_platform/ont_platform/api/routes/maintenance.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Phase 6 maintenance loop routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ont_platform.api.db_deps import get_db
|
||||
from ont_platform.core.maintenance import (
|
||||
MaintenanceLoopService,
|
||||
MaintenancePermissionError,
|
||||
MaintenanceProposalNotFoundError,
|
||||
maintenance_proposal_to_dict,
|
||||
maintenance_run_to_dict,
|
||||
)
|
||||
from ont_platform.storage.models import MaintenanceProposalStatus
|
||||
|
||||
router = APIRouter(prefix="/api/v1/maintenance", tags=["maintenance"])
|
||||
|
||||
|
||||
class MaintenanceRunRequest(BaseModel):
|
||||
project_id: str = "default"
|
||||
requested_by: str = "system"
|
||||
actor_role: str = "admin"
|
||||
low_confidence_threshold: float = Field(default=0.65, ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class ProposalReviewRequest(BaseModel):
|
||||
reviewed_by: str
|
||||
actor_role: str = "admin"
|
||||
approve: bool
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
@router.post("/runs")
|
||||
async def start_maintenance_run(
|
||||
request: Annotated[MaintenanceRunRequest, Body()],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> dict:
|
||||
service = MaintenanceLoopService(db)
|
||||
try:
|
||||
run = await service.run(
|
||||
project_id=request.project_id,
|
||||
requested_by=request.requested_by,
|
||||
actor_role=request.actor_role,
|
||||
low_confidence_threshold=request.low_confidence_threshold,
|
||||
)
|
||||
except MaintenancePermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
db.commit()
|
||||
raise HTTPException(status_code=500, detail=f"Maintenance run failed: {exc}") from exc
|
||||
db.commit()
|
||||
return {"status": "success", "run": maintenance_run_to_dict(run)}
|
||||
|
||||
|
||||
@router.get("/runs")
|
||||
def list_maintenance_runs(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
project_id: Annotated[str, Query()] = "default",
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 50,
|
||||
) -> dict:
|
||||
service = MaintenanceLoopService(db)
|
||||
runs = service.list_runs(project_id=project_id, limit=limit)
|
||||
return {"runs": [maintenance_run_to_dict(run) for run in runs]}
|
||||
|
||||
|
||||
@router.get("/proposals")
|
||||
def list_maintenance_proposals(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
project_id: Annotated[str, Query()] = "default",
|
||||
status: Annotated[str | None, Query()] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=200)] = 100,
|
||||
) -> dict:
|
||||
service = MaintenanceLoopService(db)
|
||||
try:
|
||||
proposals = service.list_proposals(
|
||||
project_id=project_id,
|
||||
status=MaintenanceProposalStatus(status) if status else None,
|
||||
limit=limit,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid proposal status: {status}") from exc
|
||||
return {"proposals": [maintenance_proposal_to_dict(proposal) for proposal in proposals]}
|
||||
|
||||
|
||||
@router.post("/proposals/{proposal_id}/review")
|
||||
async def review_maintenance_proposal(
|
||||
proposal_id: str,
|
||||
request: Annotated[ProposalReviewRequest, Body()],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> dict:
|
||||
service = MaintenanceLoopService(db)
|
||||
try:
|
||||
proposal = await service.review_proposal(
|
||||
proposal_id=proposal_id,
|
||||
reviewed_by=request.reviewed_by,
|
||||
actor_role=request.actor_role,
|
||||
approve=request.approve,
|
||||
reason=request.reason,
|
||||
)
|
||||
except MaintenancePermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
except MaintenanceProposalNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
db.commit()
|
||||
return {"status": "success", "proposal": maintenance_proposal_to_dict(proposal)}
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
335
ontology_platform/ont_platform/api/routes/review.py
Normal file
335
ontology_platform/ont_platform/api/routes/review.py
Normal file
@@ -0,0 +1,335 @@
|
||||
"""Phase 2 candidate review queue API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ont_platform.api.db_deps import get_db
|
||||
from ont_platform.core.review import CandidatePromotionService, ReviewService
|
||||
from ont_platform.core.review.review_service import (
|
||||
EvidenceRequiredError,
|
||||
InvalidReviewTransitionError,
|
||||
review_decision_to_dict,
|
||||
)
|
||||
from ont_platform.storage.candidate_repository import CandidateNotFoundError, CandidateRepository
|
||||
from ont_platform.storage.models import CandidateEntity, CandidateKind, CandidateRelation
|
||||
|
||||
router = APIRouter(prefix="/api/v1/review", tags=["review"])
|
||||
|
||||
|
||||
class CandidateIngestRequest(BaseModel):
|
||||
project_id: str = "default"
|
||||
document_id: str
|
||||
entities: list[dict[str, Any]] = Field(default_factory=list)
|
||||
relations: list[dict[str, Any]] = Field(default_factory=list)
|
||||
evidence_spans: list[dict[str, Any]] = Field(default_factory=list)
|
||||
source_trust: float = Field(default=0.5, ge=0.0, le=1.0)
|
||||
validation_passed: bool = True
|
||||
validation_errors: list[str] = Field(default_factory=list)
|
||||
validation_issues: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ReviewDecisionRequest(BaseModel):
|
||||
reviewed_by: str = "user"
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class BulkApproveRequest(ReviewDecisionRequest):
|
||||
candidate_kind: CandidateKind
|
||||
candidate_ids: list[str]
|
||||
|
||||
|
||||
@router.post("/ingest/lightweight")
|
||||
def ingest_lightweight_candidates(
|
||||
request: CandidateIngestRequest,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> dict[str, Any]:
|
||||
repository = CandidateRepository(db)
|
||||
batch = repository.save_lightweight_result(
|
||||
project_id=request.project_id,
|
||||
document_id=request.document_id,
|
||||
result=request.model_dump(),
|
||||
source_trust=request.source_trust,
|
||||
validation_passed=request.validation_passed,
|
||||
)
|
||||
db.commit()
|
||||
return {"status": "success", "source_type": "lightweight", **batch.to_dict()}
|
||||
|
||||
|
||||
@router.post("/ingest/ontocast")
|
||||
def ingest_ontocast_candidates(
|
||||
request: CandidateIngestRequest,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> dict[str, Any]:
|
||||
repository = CandidateRepository(db)
|
||||
batch = repository.save_ontocast_result(
|
||||
project_id=request.project_id,
|
||||
document_id=request.document_id,
|
||||
result=request.model_dump(),
|
||||
source_trust=request.source_trust,
|
||||
validation_passed=request.validation_passed,
|
||||
)
|
||||
db.commit()
|
||||
return {"status": "success", "source_type": "ontocast", **batch.to_dict()}
|
||||
|
||||
|
||||
@router.get("/candidates")
|
||||
def list_candidates(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
project_id: Annotated[str, Query()] = "default",
|
||||
status: Annotated[str | None, Query()] = None,
|
||||
source_type: Annotated[str | None, Query()] = None,
|
||||
) -> dict[str, Any]:
|
||||
repository = CandidateRepository(db)
|
||||
candidates = repository.list_candidates(
|
||||
project_id=project_id,
|
||||
status=status,
|
||||
source_type=source_type,
|
||||
)
|
||||
return {
|
||||
"entities": [_candidate_to_dict(entity, CandidateKind.ENTITY) for entity in candidates["entities"]],
|
||||
"relations": [
|
||||
_candidate_to_dict(relation, CandidateKind.RELATION)
|
||||
for relation in candidates["relations"]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/validation/issues")
|
||||
def list_validation_issues(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
project_id: Annotated[str, Query()] = "default",
|
||||
document_id: Annotated[str | None, Query()] = None,
|
||||
candidate_id: Annotated[str | None, Query()] = None,
|
||||
) -> dict[str, Any]:
|
||||
repository = CandidateRepository(db)
|
||||
issues = repository.list_validation_issues(
|
||||
project_id=project_id,
|
||||
document_id=document_id,
|
||||
candidate_id=candidate_id,
|
||||
)
|
||||
return {"issues": [_validation_issue_to_dict(issue) for issue in issues]}
|
||||
|
||||
|
||||
@router.get("/candidates/{candidate_kind}/{candidate_id}")
|
||||
def get_candidate_detail(
|
||||
candidate_kind: CandidateKind,
|
||||
candidate_id: str,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> dict[str, Any]:
|
||||
repository = CandidateRepository(db)
|
||||
try:
|
||||
candidate = repository.get_candidate(
|
||||
candidate_kind=candidate_kind,
|
||||
candidate_id=candidate_id,
|
||||
)
|
||||
except CandidateNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return {
|
||||
"candidate": _candidate_to_dict(candidate, candidate_kind),
|
||||
"history": [
|
||||
review_decision_to_dict(decision)
|
||||
for decision in repository.review_history(
|
||||
candidate_kind=candidate_kind,
|
||||
candidate_id=candidate_id,
|
||||
)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/candidates/{candidate_kind}/{candidate_id}/approve")
|
||||
def approve_candidate(
|
||||
candidate_kind: CandidateKind,
|
||||
candidate_id: str,
|
||||
request: Annotated[ReviewDecisionRequest, Body()],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> dict[str, Any]:
|
||||
decision = _apply_review_decision(
|
||||
db=db,
|
||||
action="approve",
|
||||
candidate_kind=candidate_kind,
|
||||
candidate_id=candidate_id,
|
||||
reviewed_by=request.reviewed_by,
|
||||
reason=request.reason,
|
||||
)
|
||||
db.commit()
|
||||
return {"status": "success", "decision": review_decision_to_dict(decision)}
|
||||
|
||||
|
||||
@router.post("/candidates/{candidate_kind}/{candidate_id}/auto-approve")
|
||||
def auto_approve_candidate(
|
||||
candidate_kind: CandidateKind,
|
||||
candidate_id: str,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
request: Annotated[ReviewDecisionRequest, Body()],
|
||||
) -> dict[str, Any]:
|
||||
decision = _apply_review_decision(
|
||||
db=db,
|
||||
action="auto_approve",
|
||||
candidate_kind=candidate_kind,
|
||||
candidate_id=candidate_id,
|
||||
reviewed_by=request.reviewed_by,
|
||||
reason=request.reason,
|
||||
)
|
||||
db.commit()
|
||||
return {"status": "success", "decision": review_decision_to_dict(decision)}
|
||||
|
||||
|
||||
@router.post("/candidates/{candidate_kind}/{candidate_id}/reject")
|
||||
def reject_candidate(
|
||||
candidate_kind: CandidateKind,
|
||||
candidate_id: str,
|
||||
request: Annotated[ReviewDecisionRequest, Body()],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> dict[str, Any]:
|
||||
decision = _apply_review_decision(
|
||||
db=db,
|
||||
action="reject",
|
||||
candidate_kind=candidate_kind,
|
||||
candidate_id=candidate_id,
|
||||
reviewed_by=request.reviewed_by,
|
||||
reason=request.reason,
|
||||
)
|
||||
db.commit()
|
||||
return {"status": "success", "decision": review_decision_to_dict(decision)}
|
||||
|
||||
|
||||
@router.post("/candidates/bulk-approve")
|
||||
def bulk_approve_candidates(
|
||||
request: BulkApproveRequest,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> dict[str, Any]:
|
||||
repository = CandidateRepository(db)
|
||||
service = ReviewService(repository)
|
||||
try:
|
||||
decisions = service.bulk_approve(
|
||||
candidate_kind=request.candidate_kind,
|
||||
candidate_ids=request.candidate_ids,
|
||||
reviewed_by=request.reviewed_by,
|
||||
reason=request.reason,
|
||||
)
|
||||
except (CandidateNotFoundError, EvidenceRequiredError, InvalidReviewTransitionError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
db.commit()
|
||||
return {
|
||||
"status": "success",
|
||||
"decisions": [review_decision_to_dict(decision) for decision in decisions],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/promote")
|
||||
def build_promotion_plan(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
project_id: Annotated[str, Query()] = "default",
|
||||
mark_promoted: Annotated[bool, Query()] = False,
|
||||
) -> dict[str, Any]:
|
||||
repository = CandidateRepository(db)
|
||||
plan = CandidatePromotionService(repository).build_commit_plan(
|
||||
project_id=project_id,
|
||||
mark_promoted=mark_promoted,
|
||||
)
|
||||
if mark_promoted:
|
||||
db.commit()
|
||||
return {"status": "success", "promotion_plan": plan.to_dict()}
|
||||
|
||||
|
||||
def _apply_review_decision(
|
||||
*,
|
||||
db: Session,
|
||||
action: str,
|
||||
candidate_kind: CandidateKind,
|
||||
candidate_id: str,
|
||||
reviewed_by: str,
|
||||
reason: str | None,
|
||||
):
|
||||
repository = CandidateRepository(db)
|
||||
service = ReviewService(repository)
|
||||
try:
|
||||
if action == "approve":
|
||||
return service.approve(
|
||||
candidate_kind=candidate_kind,
|
||||
candidate_id=candidate_id,
|
||||
reviewed_by=reviewed_by,
|
||||
reason=reason,
|
||||
)
|
||||
if action == "auto_approve":
|
||||
return service.auto_approve(
|
||||
candidate_kind=candidate_kind,
|
||||
candidate_id=candidate_id,
|
||||
reviewed_by=reviewed_by,
|
||||
reason=reason,
|
||||
)
|
||||
return service.reject(
|
||||
candidate_kind=candidate_kind,
|
||||
candidate_id=candidate_id,
|
||||
reviewed_by=reviewed_by,
|
||||
reason=reason,
|
||||
)
|
||||
except CandidateNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except EvidenceRequiredError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except InvalidReviewTransitionError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
def _candidate_to_dict(
|
||||
candidate: CandidateEntity | CandidateRelation,
|
||||
candidate_kind: CandidateKind,
|
||||
) -> dict[str, Any]:
|
||||
data = {
|
||||
"id": candidate.id,
|
||||
"candidate_kind": candidate_kind.value,
|
||||
"project_id": candidate.project_id,
|
||||
"document_id": candidate.document_id,
|
||||
"source_type": candidate.source_type.value,
|
||||
"created_by": candidate.created_by,
|
||||
"confidence": candidate.confidence,
|
||||
"source_trust": candidate.source_trust,
|
||||
"validation_passed": candidate.validation_passed,
|
||||
"evidence_ids": candidate.evidence_ids or [],
|
||||
"review_status": candidate.review_status.value,
|
||||
"reviewed_by": candidate.reviewed_by,
|
||||
"review_reason": candidate.review_reason,
|
||||
"metadata": candidate.metadata_ or {},
|
||||
}
|
||||
if isinstance(candidate, CandidateEntity):
|
||||
data.update(
|
||||
{
|
||||
"label": candidate.label,
|
||||
"entity_type": candidate.entity_type,
|
||||
"description": candidate.description,
|
||||
}
|
||||
)
|
||||
else:
|
||||
data.update(
|
||||
{
|
||||
"source_entity_id": candidate.source_entity_id,
|
||||
"predicate": candidate.predicate,
|
||||
"target_entity_id": candidate.target_entity_id,
|
||||
}
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def _validation_issue_to_dict(issue) -> dict[str, Any]:
|
||||
return {
|
||||
"id": issue.id,
|
||||
"project_id": issue.project_id,
|
||||
"document_id": issue.document_id,
|
||||
"candidate_id": issue.candidate_id,
|
||||
"candidate_kind": issue.candidate_kind.value if issue.candidate_kind else None,
|
||||
"severity": issue.severity.value,
|
||||
"code": issue.code,
|
||||
"message": issue.message,
|
||||
"source": issue.source,
|
||||
"metadata": issue.metadata_ or {},
|
||||
"created_at": issue.created_at.isoformat() if issue.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
Reference in New Issue
Block a user