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