docs
This commit is contained in:
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