📄 brief_router.py 7,925 bytes Apr 22, 2026 📋 Raw

"""FastAPI router for Content Brief v2 — struggle-first pipeline."""

import os
from fastapi import APIRouter, HTTPException, Query, Header, Depends
from typing import Optional

from .models import (
BriefListResponse,
BriefDetailResponse,
CreateBriefRequest,
UpdateBriefRequest,
SubmitBriefResponse,
ApproveBriefResponse,
RejectBriefResponse,
BriefOutputResponse,
StruggleScoreResponse,
StyleReferenceListResponse,
StyleReference,
)
from .brief_service import (
create_brief, get_brief, list_briefs, update_brief,
submit_brief, approve_brief, reject_brief, update_generation_status,
calculate_struggle_score,
BriefNotFoundError, BriefValidationError,
)
from .service import list_posts # For style references

from .notifications import on_brief_submitted, on_brief_approved

from .generation_v2 import trigger_generation
from .notifications import on_brief_submitted, on_brief_approved

Admin token auth — same as main router

ADMIN_TOKEN = os.getenv("BLOG_ADMIN_TOKEN", "changeme-please-update")

def verify_admin_token(x_admin_token: str = Header(None)) -> str:
"""Verify admin token from header."""
if x_admin_token != ADMIN_TOKEN:
raise HTTPException(status_code=401, detail="Unauthorized - invalid or missing token")
return x_admin_token

router = APIRouter(tags=["content-briefs"])

@router.post("/", response_model=BriefDetailResponse)
async def api_create_brief(
request: CreateBriefRequest,
token: str = Depends(verify_admin_token),
):
"""Create a new content brief."""
brief = create_brief(request)
return {"brief": brief}

@router.get("/", response_model=BriefListResponse)
async def api_list_briefs(
status: Optional[str] = Query(None, description="Filter by status"),
created_by: Optional[str] = Query(None, description="Filter by creator"),
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
token: str = Depends(verify_admin_token),
):
"""List content briefs with optional filtering."""
briefs, total = list_briefs(status=status, created_by=created_by, page=page, per_page=per_page)
total_pages = (total + per_page - 1) // per_page
return {
"briefs": briefs,
"total": total,
"page": page,
"per_page": per_page,
"total_pages": total_pages,
}

@router.get("/{brief_id}", response_model=BriefDetailResponse)
async def api_get_brief(
brief_id: str,
token: str = Depends(verify_admin_token),
):
"""Get a single content brief by ID."""
try:
brief = get_brief(brief_id)
return {"brief": brief}
except BriefNotFoundError:
raise HTTPException(status_code=404, detail=f"Brief '{brief_id}' not found")

@router.patch("/{brief_id}", response_model=BriefDetailResponse)
async def api_update_brief(
brief_id: str,
request: UpdateBriefRequest,
token: str = Depends(verify_admin_token),
):
"""Update an existing brief. Only works in draft or rejected status."""
try:
brief = update_brief(brief_id, request)
return {"brief": brief}
except BriefNotFoundError:
raise HTTPException(status_code=404, detail=f"Brief '{brief_id}' not found")
except BriefValidationError as e:
raise HTTPException(status_code=400, detail=str(e))

@router.post("/{brief_id}/submit", response_model=SubmitBriefResponse)
async def api_submit_brief(
brief_id: str,
token: str = Depends(verify_admin_token),
):
"""Submit a brief for approval. Validates voice checklist first."""
try:
brief = submit_brief(brief_id)
# Notify Matt via Telegram
on_brief_submitted(brief.id, brief.title, brief.created_by)
return {
"id": brief.id,
"status": brief.status,
"message": "Brief submitted for approval. Awaiting review.",
}
except BriefNotFoundError:
raise HTTPException(status_code=404, detail=f"Brief '{brief_id}' not found")
except BriefValidationError as e:
raise HTTPException(status_code=400, detail=str(e))

@router.post("/{brief_id}/approve", response_model=ApproveBriefResponse)
async def api_approve_brief(
brief_id: str,
token: str = Depends(verify_admin_token),
):
"""Approve a brief and trigger generation. Matt-only."""
try:
brief = approve_brief(brief_id, approved_by="matt")
# Notify via Telegram
on_brief_approved(brief.id, brief.title, brief.generation_job_id or "")
# Trigger content generation
trigger_generation(brief_id)
return {
"id": brief.id,
"status": brief.status,
"message": "Brief approved. Generation started.",
"generation_job_id": brief.generation_job_id,
}
except BriefNotFoundError:
raise HTTPException(status_code=404, detail=f"Brief '{brief_id}' not found")
except BriefValidationError as e:
raise HTTPException(status_code=400, detail=str(e))

@router.post("/{brief_id}/reject", response_model=RejectBriefResponse)
async def api_reject_brief(
brief_id: str,
feedback: str = Query("", description="Rejection feedback"),
token: str = Depends(verify_admin_token),
):
"""Reject a brief and return to draft with feedback."""
try:
brief = reject_brief(brief_id, feedback=feedback)
return {
"id": brief.id,
"status": brief.status,
"message": f"Brief rejected. Feedback appended to reflection.",
}
except BriefNotFoundError:
raise HTTPException(status_code=404, detail=f"Brief '{brief_id}' not found")
except BriefValidationError as e:
raise HTTPException(status_code=400, detail=str(e))

@router.get("/{brief_id}/output", response_model=BriefOutputResponse)
async def api_get_brief_output(
brief_id: str,
token: str = Depends(verify_admin_token),
):
"""Get generated content output for a brief."""
try:
brief = get_brief(brief_id)
if brief.status != "completed":
raise HTTPException(status_code=400, detail=f"Brief status is '{brief.status}', not 'completed'")
return {
"id": brief.id,
"title": brief.title,
"content_md": brief.content_output or "",
"content_html": brief.content_html or "",
"struggle_score": brief.struggle_score,
"generated_at": brief.updated_at,
}
except BriefNotFoundError:
raise HTTPException(status_code=404, detail=f"Brief '{brief_id}' not found")

@router.post("/{brief_id}/score", response_model=StruggleScoreResponse)
async def api_calculate_score(
brief_id: str,
token: str = Depends(verify_admin_token),
):
"""Calculate struggle score for a brief's generated content."""
try:
brief = get_brief(brief_id)
if not brief.content_output:
raise HTTPException(status_code=400, detail="Brief has no generated content to score")
result = calculate_struggle_score(brief.content_output)
return {
"score": result["score"],
"max_score": 100.0,
"checks": result["checks"],
"interpretation": result["interpretation"],
}
except BriefNotFoundError:
raise HTTPException(status_code=404, detail=f"Brief '{brief_id}' not found")

@router.get("/style-references", response_model=StyleReferenceListResponse)
async def api_list_style_references(
limit: int = Query(20, ge=1, le=100),
token: str = Depends(verify_admin_token),
):
"""List published posts that can be used as style references."""
posts, _ = list_posts(status="published", page=1, per_page=limit)
styles = [
StyleReference(
slug=p.slug,
title=p.title,
excerpt=p.excerpt or "",
)
for p in posts
]
return {"styles": styles}