chrysopedia/backend/routers/review.py
jlightner 4b0914b12b fix: restore complete project tree from ub01 canonical state
Auto-mode commit 7aa33cd accidentally deleted 78 files (14,814 lines) during M005
execution. Subsequent commits rebuilt some frontend files but backend/, alembic/,
tests/, whisper/, docker configs, and prompts were never restored in this repo.

This commit restores the full project tree by syncing from ub01's working directory,
which has all M001-M007 features running in production containers.

Restored: backend/ (config, models, routers, database, redis, search_service, worker),
alembic/ (6 migrations), docker/ (Dockerfiles, nginx, compose), prompts/ (4 stages),
tests/, whisper/, README.md, .env.example, chrysopedia-spec.md
2026-03-31 02:10:41 +00:00

375 lines
12 KiB
Python

"""Review queue endpoints for Chrysopedia API.
Provides admin review workflow: list queue, stats, approve, reject,
edit, split, merge key moments, and toggle review/auto mode via Redis.
"""
import logging
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from config import get_settings
from database import get_session
from models import Creator, KeyMoment, KeyMomentContentType, ReviewStatus, SourceVideo
from redis_client import get_redis
from schemas import (
KeyMomentRead,
MomentEditRequest,
MomentMergeRequest,
MomentSplitRequest,
ReviewModeResponse,
ReviewModeUpdate,
ReviewQueueItem,
ReviewQueueResponse,
ReviewStatsResponse,
)
logger = logging.getLogger("chrysopedia.review")
router = APIRouter(prefix="/review", tags=["review"])
REDIS_MODE_KEY = "chrysopedia:review_mode"
VALID_STATUSES = {"pending", "approved", "edited", "rejected", "all"}
# ── Helpers ──────────────────────────────────────────────────────────────────
def _moment_to_queue_item(
moment: KeyMoment, video_filename: str, creator_name: str
) -> ReviewQueueItem:
"""Convert a KeyMoment ORM instance + joined fields to a ReviewQueueItem."""
data = KeyMomentRead.model_validate(moment).model_dump()
data["video_filename"] = video_filename
data["creator_name"] = creator_name
return ReviewQueueItem(**data)
# ── Endpoints ────────────────────────────────────────────────────────────────
@router.get("/queue", response_model=ReviewQueueResponse)
async def list_queue(
status: Annotated[str, Query()] = "pending",
offset: Annotated[int, Query(ge=0)] = 0,
limit: Annotated[int, Query(ge=1, le=1000)] = 50,
db: AsyncSession = Depends(get_session),
) -> ReviewQueueResponse:
"""List key moments in the review queue, filtered by status."""
if status not in VALID_STATUSES:
raise HTTPException(
status_code=400,
detail=f"Invalid status filter '{status}'. Must be one of: {', '.join(sorted(VALID_STATUSES))}",
)
# Base query joining KeyMoment → SourceVideo → Creator
base = (
select(
KeyMoment,
SourceVideo.filename.label("video_filename"),
Creator.name.label("creator_name"),
)
.join(SourceVideo, KeyMoment.source_video_id == SourceVideo.id)
.join(Creator, SourceVideo.creator_id == Creator.id)
)
if status != "all":
base = base.where(KeyMoment.review_status == ReviewStatus(status))
# Count total matching rows
count_stmt = select(func.count()).select_from(base.subquery())
total = (await db.execute(count_stmt)).scalar_one()
# Fetch paginated results
stmt = base.order_by(KeyMoment.created_at.desc()).offset(offset).limit(limit)
rows = (await db.execute(stmt)).all()
items = [
_moment_to_queue_item(row.KeyMoment, row.video_filename, row.creator_name)
for row in rows
]
return ReviewQueueResponse(items=items, total=total, offset=offset, limit=limit)
@router.get("/stats", response_model=ReviewStatsResponse)
async def get_stats(
db: AsyncSession = Depends(get_session),
) -> ReviewStatsResponse:
"""Return counts of key moments grouped by review status."""
stmt = (
select(
KeyMoment.review_status,
func.count().label("cnt"),
)
.group_by(KeyMoment.review_status)
)
result = await db.execute(stmt)
counts = {row.review_status.value: row.cnt for row in result.all()}
return ReviewStatsResponse(
pending=counts.get("pending", 0),
approved=counts.get("approved", 0),
edited=counts.get("edited", 0),
rejected=counts.get("rejected", 0),
)
@router.post("/moments/{moment_id}/approve", response_model=KeyMomentRead)
async def approve_moment(
moment_id: uuid.UUID,
db: AsyncSession = Depends(get_session),
) -> KeyMomentRead:
"""Approve a key moment for publishing."""
moment = await db.get(KeyMoment, moment_id)
if moment is None:
raise HTTPException(
status_code=404,
detail=f"Key moment {moment_id} not found",
)
moment.review_status = ReviewStatus.approved
await db.commit()
await db.refresh(moment)
logger.info("Approved key moment %s", moment_id)
return KeyMomentRead.model_validate(moment)
@router.post("/moments/{moment_id}/reject", response_model=KeyMomentRead)
async def reject_moment(
moment_id: uuid.UUID,
db: AsyncSession = Depends(get_session),
) -> KeyMomentRead:
"""Reject a key moment."""
moment = await db.get(KeyMoment, moment_id)
if moment is None:
raise HTTPException(
status_code=404,
detail=f"Key moment {moment_id} not found",
)
moment.review_status = ReviewStatus.rejected
await db.commit()
await db.refresh(moment)
logger.info("Rejected key moment %s", moment_id)
return KeyMomentRead.model_validate(moment)
@router.put("/moments/{moment_id}", response_model=KeyMomentRead)
async def edit_moment(
moment_id: uuid.UUID,
body: MomentEditRequest,
db: AsyncSession = Depends(get_session),
) -> KeyMomentRead:
"""Update editable fields of a key moment and set status to edited."""
moment = await db.get(KeyMoment, moment_id)
if moment is None:
raise HTTPException(
status_code=404,
detail=f"Key moment {moment_id} not found",
)
update_data = body.model_dump(exclude_unset=True)
# Convert content_type string to enum if provided
if "content_type" in update_data and update_data["content_type"] is not None:
try:
update_data["content_type"] = KeyMomentContentType(update_data["content_type"])
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Invalid content_type '{update_data['content_type']}'",
)
for field, value in update_data.items():
setattr(moment, field, value)
moment.review_status = ReviewStatus.edited
await db.commit()
await db.refresh(moment)
logger.info("Edited key moment %s (fields: %s)", moment_id, list(update_data.keys()))
return KeyMomentRead.model_validate(moment)
@router.post("/moments/{moment_id}/split", response_model=list[KeyMomentRead])
async def split_moment(
moment_id: uuid.UUID,
body: MomentSplitRequest,
db: AsyncSession = Depends(get_session),
) -> list[KeyMomentRead]:
"""Split a key moment into two at the given timestamp."""
moment = await db.get(KeyMoment, moment_id)
if moment is None:
raise HTTPException(
status_code=404,
detail=f"Key moment {moment_id} not found",
)
# Validate split_time is strictly between start_time and end_time
if body.split_time <= moment.start_time or body.split_time >= moment.end_time:
raise HTTPException(
status_code=400,
detail=(
f"split_time ({body.split_time}) must be strictly between "
f"start_time ({moment.start_time}) and end_time ({moment.end_time})"
),
)
# Update original moment to [start_time, split_time)
original_end = moment.end_time
moment.end_time = body.split_time
moment.review_status = ReviewStatus.pending
# Create new moment for [split_time, end_time]
new_moment = KeyMoment(
source_video_id=moment.source_video_id,
technique_page_id=moment.technique_page_id,
title=f"{moment.title} (split)",
summary=moment.summary,
start_time=body.split_time,
end_time=original_end,
content_type=moment.content_type,
plugins=moment.plugins,
review_status=ReviewStatus.pending,
raw_transcript=moment.raw_transcript,
)
db.add(new_moment)
await db.commit()
await db.refresh(moment)
await db.refresh(new_moment)
logger.info(
"Split key moment %s at %.2f → original [%.2f, %.2f), new [%.2f, %.2f]",
moment_id, body.split_time,
moment.start_time, moment.end_time,
new_moment.start_time, new_moment.end_time,
)
return [
KeyMomentRead.model_validate(moment),
KeyMomentRead.model_validate(new_moment),
]
@router.post("/moments/{moment_id}/merge", response_model=KeyMomentRead)
async def merge_moments(
moment_id: uuid.UUID,
body: MomentMergeRequest,
db: AsyncSession = Depends(get_session),
) -> KeyMomentRead:
"""Merge two key moments into one."""
if moment_id == body.target_moment_id:
raise HTTPException(
status_code=400,
detail="Cannot merge a moment with itself",
)
source = await db.get(KeyMoment, moment_id)
if source is None:
raise HTTPException(
status_code=404,
detail=f"Key moment {moment_id} not found",
)
target = await db.get(KeyMoment, body.target_moment_id)
if target is None:
raise HTTPException(
status_code=404,
detail=f"Target key moment {body.target_moment_id} not found",
)
# Both must belong to the same source video
if source.source_video_id != target.source_video_id:
raise HTTPException(
status_code=400,
detail="Cannot merge moments from different source videos",
)
# Merge: combined summary, min start, max end
source.summary = f"{source.summary}\n\n{target.summary}"
source.start_time = min(source.start_time, target.start_time)
source.end_time = max(source.end_time, target.end_time)
source.review_status = ReviewStatus.pending
# Delete target
await db.delete(target)
await db.commit()
await db.refresh(source)
logger.info(
"Merged key moment %s with %s → [%.2f, %.2f]",
moment_id, body.target_moment_id,
source.start_time, source.end_time,
)
return KeyMomentRead.model_validate(source)
@router.get("/moments/{moment_id}", response_model=ReviewQueueItem)
async def get_moment(
moment_id: uuid.UUID,
db: AsyncSession = Depends(get_session),
) -> ReviewQueueItem:
"""Get a single key moment by ID with video and creator info."""
stmt = (
select(KeyMoment, SourceVideo.file_path, Creator.name)
.join(SourceVideo, KeyMoment.source_video_id == SourceVideo.id)
.join(Creator, SourceVideo.creator_id == Creator.id)
.where(KeyMoment.id == moment_id)
)
result = await db.execute(stmt)
row = result.one_or_none()
if row is None:
raise HTTPException(status_code=404, detail=f"Moment {moment_id} not found")
moment, file_path, creator_name = row
return _moment_to_queue_item(moment, file_path or "", creator_name)
@router.get("/mode", response_model=ReviewModeResponse)
async def get_mode() -> ReviewModeResponse:
"""Get the current review mode (review vs auto)."""
settings = get_settings()
try:
redis = await get_redis()
try:
value = await redis.get(REDIS_MODE_KEY)
if value is not None:
return ReviewModeResponse(review_mode=value.lower() == "true")
finally:
await redis.aclose()
except Exception as exc:
# Redis unavailable — fall back to config default
logger.warning("Redis unavailable for mode read, using config default: %s", exc)
return ReviewModeResponse(review_mode=settings.review_mode)
@router.put("/mode", response_model=ReviewModeResponse)
async def set_mode(
body: ReviewModeUpdate,
) -> ReviewModeResponse:
"""Set the review mode (review vs auto)."""
try:
redis = await get_redis()
try:
await redis.set(REDIS_MODE_KEY, str(body.review_mode))
finally:
await redis.aclose()
except Exception as exc:
logger.error("Failed to set review mode in Redis: %s", exc)
raise HTTPException(
status_code=503,
detail=f"Redis unavailable: {exc}",
)
logger.info("Review mode set to %s", body.review_mode)
return ReviewModeResponse(review_mode=body.review_mode)