Phase 3 implementation: - AI session analysis service that generates flow proposals from resolved sessions - APScheduler job for batch processing pending analyses (max_instances=1) - Knowledge gap detection (weak options, high escalation signals) - Flow proposals CRUD with team admin review workflow (approve/edit/dismiss/reject) - FlowPilot analytics dashboard with confidence tiers, PSA metrics, knowledge gaps - In-session script generator component - Review queue page with filtering and proposal detail panel Bug fixes from review (12 total): - Fix "Edit & Publish" navigating to non-existent /editor/new route - Hide Approve button for enhancement proposals (require Edit & Publish) - Add max_instances=1 to scheduler to prevent TOCTOU race - Fix eventual_success case() double-counting failed retries - Add tree_structure validation before creating tree from proposal - Simplify script generator rendering condition - Add severity style fallback, toFixed on rates, Link instead of <a href> - Add toast.warning on dismiss failure, fix dedup for domain-less sessions - Cast Decimal to int in knowledge gap evidence dicts Also updates CLAUDE.md with lessons 67-71 and Phase 3 project structure. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
307 lines
11 KiB
Python
307 lines
11 KiB
Python
"""Review Queue API — CRUD for flow proposals.
|
|
|
|
Endpoints for listing, reviewing, and managing Knowledge Flywheel proposals:
|
|
GET /flow-proposals — List proposals (filterable)
|
|
GET /flow-proposals/stats — Dashboard stats
|
|
GET /flow-proposals/{id} — Get proposal detail
|
|
POST /flow-proposals/{id}/review — Approve, reject, modify, or dismiss
|
|
"""
|
|
import logging
|
|
import uuid
|
|
from datetime import datetime, timezone, timedelta
|
|
from typing import Annotated, Optional
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
|
from sqlalchemy import select, func, case
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from app.core.rate_limit import limiter
|
|
from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin, require_team_admin
|
|
from app.models.user import User
|
|
from app.models.tree import Tree
|
|
from app.models.flow_proposal import FlowProposal
|
|
from app.schemas.flow_proposal import (
|
|
FlowProposalSummary,
|
|
FlowProposalDetail,
|
|
FlowProposalStats,
|
|
ReviewProposalRequest,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/flow-proposals", tags=["flow-proposals"])
|
|
|
|
|
|
# ── List proposals ──
|
|
|
|
@router.get("", response_model=list[FlowProposalSummary])
|
|
@limiter.limit("30/minute")
|
|
async def list_proposals(
|
|
request: Request,
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
_: None = Depends(require_engineer_or_admin),
|
|
proposal_status: Optional[str] = Query(None, alias="status"),
|
|
proposal_type: Optional[str] = Query(None, alias="type"),
|
|
domain: Optional[str] = Query(None),
|
|
sort_by: str = Query("newest", pattern="^(newest|confidence|sessions)$"),
|
|
skip: int = Query(0, ge=0),
|
|
limit: int = Query(20, ge=1, le=100),
|
|
):
|
|
"""List flow proposals for the current user's account."""
|
|
if not current_user.account_id:
|
|
return []
|
|
|
|
query = (
|
|
select(FlowProposal)
|
|
.where(FlowProposal.account_id == current_user.account_id)
|
|
)
|
|
|
|
if proposal_status:
|
|
query = query.where(FlowProposal.status == proposal_status)
|
|
if proposal_type:
|
|
query = query.where(FlowProposal.proposal_type == proposal_type)
|
|
if domain:
|
|
query = query.where(FlowProposal.problem_domain == domain)
|
|
|
|
# Sorting
|
|
if sort_by == "confidence":
|
|
query = query.order_by(FlowProposal.confidence_score.desc())
|
|
elif sort_by == "sessions":
|
|
query = query.order_by(FlowProposal.supporting_session_count.desc())
|
|
else: # newest
|
|
query = query.order_by(FlowProposal.created_at.desc())
|
|
|
|
query = query.offset(skip).limit(limit)
|
|
|
|
result = await db.execute(query)
|
|
proposals = result.scalars().all()
|
|
|
|
return [FlowProposalSummary.model_validate(p) for p in proposals]
|
|
|
|
|
|
# ── Stats ──
|
|
|
|
@router.get("/stats", response_model=FlowProposalStats)
|
|
@limiter.limit("30/minute")
|
|
async def get_proposal_stats(
|
|
request: Request,
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
_: None = Depends(require_engineer_or_admin),
|
|
):
|
|
"""Get review queue dashboard stats."""
|
|
if not current_user.account_id:
|
|
return FlowProposalStats(
|
|
pending_count=0, approved_this_week=0, rejected_this_week=0,
|
|
auto_reinforced_this_week=0, top_domains=[],
|
|
)
|
|
|
|
week_ago = datetime.now(timezone.utc) - timedelta(days=7)
|
|
|
|
# Count pending
|
|
pending_result = await db.execute(
|
|
select(func.count(FlowProposal.id))
|
|
.where(
|
|
FlowProposal.account_id == current_user.account_id,
|
|
FlowProposal.status == "pending",
|
|
)
|
|
)
|
|
pending_count = pending_result.scalar() or 0
|
|
|
|
# Reviewed this week (approved/rejected/modified use reviewed_at)
|
|
reviewed_result = await db.execute(
|
|
select(
|
|
FlowProposal.status,
|
|
func.count(FlowProposal.id),
|
|
)
|
|
.where(
|
|
FlowProposal.account_id == current_user.account_id,
|
|
FlowProposal.reviewed_at >= week_ago,
|
|
FlowProposal.status.in_(["approved", "modified", "rejected", "dismissed"]),
|
|
)
|
|
.group_by(FlowProposal.status)
|
|
)
|
|
reviewed_counts = {row[0]: row[1] for row in reviewed_result.all()}
|
|
|
|
# Auto-reinforced this week (use created_at since they have no review)
|
|
reinforced_result = await db.execute(
|
|
select(func.count(FlowProposal.id))
|
|
.where(
|
|
FlowProposal.account_id == current_user.account_id,
|
|
FlowProposal.created_at >= week_ago,
|
|
FlowProposal.status == "auto_reinforced",
|
|
)
|
|
)
|
|
auto_reinforced_count = reinforced_result.scalar() or 0
|
|
|
|
# Top domains
|
|
domain_result = await db.execute(
|
|
select(
|
|
FlowProposal.problem_domain,
|
|
func.count(FlowProposal.id).label("count"),
|
|
)
|
|
.where(
|
|
FlowProposal.account_id == current_user.account_id,
|
|
FlowProposal.status == "pending",
|
|
FlowProposal.problem_domain.isnot(None),
|
|
)
|
|
.group_by(FlowProposal.problem_domain)
|
|
.order_by(func.count(FlowProposal.id).desc())
|
|
.limit(5)
|
|
)
|
|
top_domains = [{"domain": row[0], "count": row[1]} for row in domain_result.all()]
|
|
|
|
return FlowProposalStats(
|
|
pending_count=pending_count,
|
|
approved_this_week=reviewed_counts.get("approved", 0) + reviewed_counts.get("modified", 0),
|
|
rejected_this_week=reviewed_counts.get("rejected", 0),
|
|
auto_reinforced_this_week=auto_reinforced_count,
|
|
top_domains=top_domains,
|
|
)
|
|
|
|
|
|
# ── Detail ──
|
|
|
|
@router.get("/{proposal_id}", response_model=FlowProposalDetail)
|
|
@limiter.limit("30/minute")
|
|
async def get_proposal(
|
|
request: Request,
|
|
proposal_id: UUID,
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
_: None = Depends(require_engineer_or_admin),
|
|
):
|
|
"""Get full proposal detail."""
|
|
result = await db.execute(
|
|
select(FlowProposal).where(
|
|
FlowProposal.id == proposal_id,
|
|
FlowProposal.account_id == current_user.account_id,
|
|
)
|
|
)
|
|
proposal = result.scalar_one_or_none()
|
|
if not proposal:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Proposal not found")
|
|
|
|
return FlowProposalDetail.model_validate(proposal)
|
|
|
|
|
|
# ── Review ──
|
|
|
|
@router.post("/{proposal_id}/review", response_model=FlowProposalDetail)
|
|
@limiter.limit("10/minute")
|
|
async def review_proposal(
|
|
request: Request,
|
|
proposal_id: UUID,
|
|
data: ReviewProposalRequest,
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
_: None = Depends(require_team_admin),
|
|
):
|
|
"""Review a proposal: approve, reject, modify, or dismiss."""
|
|
result = await db.execute(
|
|
select(FlowProposal).where(
|
|
FlowProposal.id == proposal_id,
|
|
FlowProposal.account_id == current_user.account_id,
|
|
)
|
|
)
|
|
proposal = result.scalar_one_or_none()
|
|
if not proposal:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Proposal not found")
|
|
|
|
if proposal.status not in ("pending", "dismissed"):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Cannot review proposal in status: {proposal.status}",
|
|
)
|
|
|
|
proposal.reviewed_by = current_user.id
|
|
proposal.reviewed_at = datetime.now(timezone.utc)
|
|
proposal.reviewer_notes = data.reviewer_notes
|
|
|
|
if data.action == "approve":
|
|
if proposal.proposal_type == "new_flow":
|
|
flow_data = proposal.proposed_flow_data
|
|
new_tree = await _create_tree_from_proposal(proposal, flow_data, current_user, db)
|
|
proposal.status = "approved"
|
|
proposal.published_flow_id = new_tree.id
|
|
elif proposal.proposal_type in ("enhancement", "branch_addition"):
|
|
# Enhancement proposals contain diffs, not complete tree structures.
|
|
# Direct approval requires modified_flow_data with the complete merged structure.
|
|
# Redirect reviewers to use "Edit & Publish" for enhancements.
|
|
if not data.modified_flow_data:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Enhancement proposals require 'Edit & Publish' to merge changes into the existing flow. Use the modify action with modified_flow_data.",
|
|
)
|
|
new_tree = await _create_tree_from_proposal(proposal, data.modified_flow_data, current_user, db)
|
|
proposal.status = "approved"
|
|
proposal.published_flow_id = new_tree.id
|
|
else:
|
|
# auto_reinforced shouldn't reach here, but handle gracefully
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Cannot approve proposal of type: {proposal.proposal_type}",
|
|
)
|
|
|
|
elif data.action == "modify":
|
|
if not data.modified_flow_data:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="modified_flow_data is required for modify action",
|
|
)
|
|
new_tree = await _create_tree_from_proposal(proposal, data.modified_flow_data, current_user, db)
|
|
proposal.status = "modified"
|
|
proposal.published_flow_id = new_tree.id
|
|
|
|
elif data.action == "reject":
|
|
proposal.status = "rejected"
|
|
|
|
elif data.action == "dismiss":
|
|
proposal.status = "dismissed"
|
|
|
|
await db.commit()
|
|
|
|
return FlowProposalDetail.model_validate(proposal)
|
|
|
|
|
|
async def _create_tree_from_proposal(
|
|
proposal: FlowProposal,
|
|
flow_data: dict,
|
|
user: User,
|
|
db: AsyncSession,
|
|
) -> Tree:
|
|
"""Create a new Tree from proposal flow data."""
|
|
tree_structure = flow_data.get("tree_structure", flow_data)
|
|
match_keywords = flow_data.get("match_keywords", [])
|
|
|
|
if not tree_structure or not isinstance(tree_structure, dict) or not tree_structure.get("id"):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="Proposal has no valid tree structure. Use 'Edit & Publish' to build the flow manually.",
|
|
)
|
|
|
|
new_tree = Tree(
|
|
id=uuid.uuid4(),
|
|
name=proposal.title,
|
|
description=proposal.description,
|
|
tree_type="troubleshooting",
|
|
tree_structure=tree_structure,
|
|
author_id=user.id,
|
|
account_id=proposal.account_id,
|
|
team_id=proposal.team_id,
|
|
origin="ai_generated" if proposal.proposal_type == "new_flow" else "ai_enhanced",
|
|
source_session_id=proposal.source_session_id,
|
|
match_keywords=match_keywords,
|
|
)
|
|
db.add(new_tree)
|
|
await db.flush()
|
|
|
|
logger.info(
|
|
"Created tree %s from proposal %s (%s)",
|
|
new_tree.id, proposal.id, proposal.proposal_type,
|
|
)
|
|
return new_tree
|