193 lines
6.7 KiB
Python
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
|