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
375 lines
12 KiB
Python
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)
|