Files
ai-worker-platform/backend/app/services/review_service.py
T

193 lines
6.7 KiB
Python

"""Review service: HITL approval/rejection flow with full context."""
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import List, Optional
from datetime import datetime
from app.models.review import Review
from app.models.task import Task
from app.models.project import Project
from app.models.worker import AIWorker
from app.models.cost import CostLog
from app.schemas.review import ReviewCreate, ReviewDecision, ReviewDetailResponse
class ReviewService:
# ---- Basic CRUD ----
@staticmethod
def list_reviews(
db: Session,
tenant_id: int,
status: Optional[str] = None,
project_id: Optional[int] = None,
skip: int = 0,
limit: int = 100,
) -> List[Review]:
q = db.query(Review).filter(Review.tenant_id == tenant_id)
if status:
q = q.filter(Review.status == status)
if project_id:
q = q.filter(Review.project_id == project_id)
return q.order_by(Review.created_at.desc()).offset(skip).limit(limit).all()
@staticmethod
def get_review(db: Session, tenant_id: int, review_id: int) -> Optional[Review]:
return (
db.query(Review)
.filter(Review.id == review_id, Review.tenant_id == tenant_id)
.first()
)
@staticmethod
def create_review(
db: Session, tenant_id: int, req: ReviewCreate
) -> Review:
review = Review(
tenant_id=tenant_id,
task_id=req.task_id,
project_id=req.project_id,
review_type=req.review_type,
status="pending",
review_content=req.review_content,
auto_check_result=req.auto_check_result,
submitted_at=datetime.utcnow(),
)
db.add(review)
db.commit()
db.refresh(review)
return review
# ---- Detail with full context ----
@staticmethod
def list_reviews_detail(
db: Session,
tenant_id: int,
status: Optional[str] = None,
project_id: Optional[int] = None,
skip: int = 0,
limit: int = 100,
) -> List[ReviewDetailResponse]:
"""List reviews with joined task/project/worker/cost context."""
reviews = ReviewService.list_reviews(
db, tenant_id, status, project_id, skip, limit
)
return [ReviewService._enrich(db, r) for r in reviews]
@staticmethod
def get_review_detail(
db: Session, tenant_id: int, review_id: int
) -> Optional[ReviewDetailResponse]:
"""Get a single review with full context."""
review = ReviewService.get_review(db, tenant_id, review_id)
if not review:
return None
return ReviewService._enrich(db, review)
@staticmethod
def _enrich(db: Session, review: Review) -> ReviewDetailResponse:
"""Join review with task, project, worker, and aggregate cost."""
task = (
db.query(Task)
.filter(Task.id == review.task_id, Task.tenant_id == review.tenant_id)
.first()
)
project = (
db.query(Project)
.filter(Project.id == review.project_id, Project.tenant_id == review.tenant_id)
.first()
)
worker = None
if task and task.worker_id:
worker = (
db.query(AIWorker)
.filter(AIWorker.id == task.worker_id, AIWorker.tenant_id == review.tenant_id)
.first()
)
# Aggregate cost data for this task
cost_row = (
db.query(
func.sum(CostLog.cost_cents).label("total_cost"),
func.sum(CostLog.total_tokens).label("total_tokens"),
func.sum(CostLog.duration_ms).label("total_duration"),
)
.filter(CostLog.task_id == review.task_id)
.first()
)
return ReviewDetailResponse(
id=review.id,
task_id=review.task_id,
project_id=review.project_id,
review_type=review.review_type,
status=review.status,
reviewer_id=review.reviewer_id,
reviewer_role=review.reviewer_role,
review_content=review.review_content,
reviewer_comment=review.reviewer_comment,
auto_check_result=review.auto_check_result,
submitted_at=review.submitted_at,
reviewed_at=review.reviewed_at,
created_at=review.created_at,
task_title=task.title if task else "",
task_description=task.description if task else "",
task_input_data=task.input_data if task else "",
task_output_data=task.output_data if task else "",
task_priority=task.priority if task else "medium",
task_type=task.task_type if task else "single_call",
task_status=task.status if task else "",
project_name=project.name if project else "",
project_description=project.description if project else "",
worker_id=worker.id if worker else None,
worker_name=worker.name if worker else None,
worker_model_name=worker.model_name if worker else None,
worker_provider=worker.provider if worker else None,
worker_system_prompt=worker.system_prompt if worker else None,
cost_cents=cost_row.total_cost or 0 if cost_row else 0,
token_usage=cost_row.total_tokens or 0 if cost_row else 0,
duration_ms=cost_row.total_duration or 0 if cost_row else 0,
)
# ---- Decision ----
@staticmethod
def make_decision(
db: Session,
tenant_id: int,
review_id: int,
reviewer_id: int,
reviewer_role: str,
req: ReviewDecision,
) -> Review:
review = ReviewService.get_review(db, tenant_id, review_id)
if not review:
raise ValueError(f"Review #{review_id} not found")
if review.status != "pending":
raise ValueError(f"Review already {review.status}")
review.status = req.status
review.reviewer_id = reviewer_id
review.reviewer_role = reviewer_role
review.reviewer_comment = req.comment
review.reviewed_at = datetime.utcnow()
task = (
db.query(Task)
.filter(Task.id == review.task_id, Task.tenant_id == tenant_id)
.first()
)
if task:
if req.status == "approved":
task.status = "done"
task.completed_at = datetime.utcnow()
task.review_status = "approved"
else:
task.status = "rejected"
task.review_status = "rejected"
db.commit()
db.refresh(review)
return review