📄 brief.py 9,742 bytes Apr 22, 2026 📋 Raw

"""Brief Router — API endpoints for content brief v2.

POST /api/v1/content/briefs Create brief
GET /api/v1/content/briefs/{id} Get brief
POST /api/v1/content/briefs/{id}/validate Validate fields
POST /api/v1/content/briefs/{id}/submit Submit for approval
POST /api/v1/content/briefs/{id}/approve Approve (Matt only)
POST /api/v1/content/briefs/{id}/reject Reject (Matt only)
GET /api/v1/content/briefs/{id}/output Get generated content
"""

import uuid
import logging
from datetime import datetime
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from sqlalchemy import desc

from ...database import get_db
from ..models.brief import ContentBrief
from ..validation.brief import validate_brief
from ..notifications.telegram import (
notify_brief_pending,
notify_brief_approved,
notify_brief_rejected,
notify_author_brief_submitted,
)

logger = logging.getLogger(name)

router = APIRouter(prefix="/api/v1/content/briefs", tags=["content-briefs"])

--- Request/Response Models ---

class BriefCreateRequest(BaseModel):
title: str = Field(..., min_length=10, max_length=200)
struggle_angle: str = Field(..., min_length=50)
origin_story: str = Field(..., min_length=100)
attempts: List[dict] = Field(..., min_length=2)
the_moment: str = Field(..., min_length=50)
the_fix: str = Field(..., min_length=50)
reflection: str = Field(..., min_length=50)
voice_checklist: dict = Field(default_factory=dict)
style_reference: Optional[str] = None
target_length: Optional[int] = Field(None, ge=500, le=5000)

class BriefResponse(BaseModel):
id: str
title: str
struggle_angle: str
origin_story: str
attempts: List[dict]
the_moment: str
the_fix: str
reflection: str
voice_checklist: dict
status: str
style_reference: Optional[str]
target_length: Optional[int]
created_by: str
created_at: str
approved_at: Optional[str]
approved_by: Optional[str]
struggle_score: Optional[float]

class BriefValidationRequest(BaseModel):
field: Optional[str] = None
value: Optional[str] = None

class BriefValidationResponse(BaseModel):
valid: bool
errors: List[dict]
warnings: List[dict]
voice_score: int

class BriefApproveRequest(BaseModel):
approved_by: str

class BriefRejectRequest(BaseModel):
reason: str

--- Authentication Helper ---

def get_current_user(request: Request) -> str:
"""Extract current user from request."""
# For now, use header or default
user = request.headers.get("X-User-Id", "anonymous")
return user

def require_matt(user_id: str) -> bool:
"""Check if user is Matt or Aundrea."""
# Matt's Telegram ID and Aundrea's (if known)
allowed = ["8386527252", "matt", "aundrea"]
return user_id in allowed

--- Endpoints ---

@router.post("", response_model=BriefResponse)
async def create_brief(
request: BriefCreateRequest,
db: Session = Depends(get_db),
current_user: str = Depends(get_current_user),
):
"""Create a new content brief (draft status)."""
brief_id = str(uuid.uuid4())

# Validate first
validation = validate_brief(request.dict())
if not validation["valid"]:
    raise HTTPException(
        status_code=400,
        detail={
            "message": "Brief validation failed",
            "errors": validation["errors"]
        }
    )

brief = ContentBrief(
    id=brief_id,
    title=request.title,
    struggle_angle=request.struggle_angle,
    origin_story=request.origin_story,
    attempts=request.attempts,
    the_moment=request.the_moment,
    the_fix=request.the_fix,
    reflection=request.reflection,
    voice_checklist=request.voice_checklist,
    status="draft",
    style_reference=request.style_reference,
    target_length=request.target_length,
    created_by=current_user,
)

db.add(brief)
db.commit()
db.refresh(brief)

return brief.to_dict()

@router.get("/{brief_id}", response_model=BriefResponse)
async def get_brief(
brief_id: str,
db: Session = Depends(get_db),
):
"""Get a brief by ID."""
brief = db.query(ContentBrief).filter(ContentBrief.id == brief_id).first()
if not brief:
raise HTTPException(status_code=404, detail="Brief not found")
return brief.to_dict()

@router.post("/{brief_id}/validate", response_model=BriefValidationResponse)
async def validate_brief_endpoint(
brief_id: str,
request: Request,
db: Session = Depends(get_db),
):
"""Validate a brief (real-time validation)."""
body = await request.json()
validation = validate_brief(body)
return BriefValidationResponse(**validation)

@router.post("/{brief_id}/submit")
async def submit_brief(
brief_id: str,
db: Session = Depends(get_db),
current_user: str = Depends(get_current_user),
):
"""Submit brief for approval (sends Telegram to Matt)."""
brief = db.query(ContentBrief).filter(ContentBrief.id == brief_id).first()
if not brief:
raise HTTPException(status_code=404, detail="Brief not found")

if brief.status != "draft":
    raise HTTPException(
        status_code=400, 
        detail=f"Brief already in status: {brief.status}"
    )

# Update status
brief.status = "pending"
db.commit()

# Send Telegram notification to Matt
notify_brief_pending(
    brief_id=brief.id,
    title=brief.title,
    struggle_angle=brief.struggle_angle,
    created_by=brief.created_by
)

# Notify author
# Note: In real implementation, get author's chat_id from user system
notify_author_brief_submitted(
    chat_id=current_user,  # This would be actual chat_id
    title=brief.title
)

return {"status": "pending", "message": "Brief submitted for approval"}

@router.post("/{brief_id}/approve")
async def approve_brief(
brief_id: str,
request: BriefApproveRequest,
db: Session = Depends(get_db),
current_user: str = Depends(get_current_user),
):
"""Approve brief and trigger generation (Matt/Aundrea only)."""
if not require_matt(current_user):
raise HTTPException(status_code=403, detail="Only household can approve")

brief = db.query(ContentBrief).filter(ContentBrief.id == brief_id).first()
if not brief:
    raise HTTPException(status_code=404, detail="Brief not found")

if brief.status != "pending":
    raise HTTPException(
        status_code=400,
        detail=f"Brief must be pending, current status: {brief.status}"
    )

# Update status
brief.status = "approved"
brief.approved_by = request.approved_by
brief.approved_at = datetime.utcnow()
db.commit()

# Notify
notify_brief_approved(
    brief_id=brief.id,
    title=brief.title,
    approved_by=request.approved_by
)

# TODO: Trigger actual generation (Phase 1)
# For now, just mark as approved

return {"status": "approved", "message": "Brief approved, generation starting"}

@router.post("/{brief_id}/reject")
async def reject_brief(
brief_id: str,
request: BriefRejectRequest,
db: Session = Depends(get_db),
current_user: str = Depends(get_current_user),
):
"""Reject brief with feedback (Matt/Aundrea only)."""
if not require_matt(current_user):
raise HTTPException(status_code=403, detail="Only household can reject")

brief = db.query(ContentBrief).filter(ContentBrief.id == brief_id).first()
if not brief:
    raise HTTPException(status_code=404, detail="Brief not found")

if brief.status != "pending":
    raise HTTPException(
        status_code=400,
        detail=f"Brief must be pending, current status: {brief.status}"
    )

# Update status
brief.status = "rejected"
db.commit()

# Notify
notify_brief_rejected(
    brief_id=brief.id,
    title=brief.title,
    reason=request.reason
)

return {"status": "rejected", "message": "Brief rejected with feedback"}

@router.get("/{brief_id}/output")
async def get_brief_output(
brief_id: str,
db: Session = Depends(get_db),
):
"""Get generated content for a brief."""
brief = db.query(ContentBrief).filter(ContentBrief.id == brief_id).first()
if not brief:
raise HTTPException(status_code=404, detail="Brief not found")

if brief.status != "completed":
    raise HTTPException(
        status_code=400,
        detail=f"Content not ready, status: {brief.status}"
    )

return {
    "brief_id": brief.id,
    "title": brief.title,
    "content": brief.content_output,
    "struggle_score": brief.struggle_score,
}

@router.get("")
async def list_briefs(
status: Optional[str] = Query(None),
db: Session = Depends(get_db),
current_user: str = Depends(get_current_user),
):
"""List briefs, optionally filtered by status."""
query = db.query(ContentBrief)

if status:
    query = query.filter(ContentBrief.status == status)

# Non-Matt users only see their own briefs
if not require_matt(current_user):
    query = query.filter(ContentBrief.created_by == current_user)

briefs = query.order_by(desc(ContentBrief.created_at)).all()

return {
    "briefs": [b.to_dict() for b in briefs],
    "count": len(briefs)
}