"""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"]