feat(knowledge-flywheel): add Phase 3 Knowledge Flywheel — AI analysis, review queue, analytics

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>
This commit is contained in:
2026-03-19 05:12:10 +00:00
parent ce118b51d8
commit 9bad49d568
42 changed files with 5427 additions and 13 deletions

View File

@@ -145,6 +145,22 @@ async def require_engineer_or_admin(
)
async def require_team_admin(
current_user: Annotated[User, Depends(get_current_active_user)]
) -> User:
"""Require team admin, account owner, or super admin role."""
if current_user.is_super_admin:
return current_user
if current_user.is_team_admin:
return current_user
if current_user.account_role == "owner":
return current_user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Team admin access required"
)
async def require_account_owner(
current_user: Annotated[User, Depends(get_current_active_user)]
) -> User:

View File

@@ -0,0 +1,306 @@
"""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

View File

@@ -0,0 +1,358 @@
"""FlowPilot Analytics API — MTTR, resolution rates, knowledge coverage.
Endpoints:
GET /analytics/flowpilot?period=30d — Main dashboard data
GET /analytics/flowpilot/knowledge-gaps — Knowledge gap report
"""
import logging
from datetime import datetime, timezone, timedelta
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy import select, func, case, cast, Date, extract
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.rate_limit import limiter
from app.api.deps import get_current_active_user, get_db, require_team_admin
from app.models.user import User
from app.models.tree import Tree
from app.models.ai_session import AISession
from app.models.flow_proposal import FlowProposal
from app.models.psa_post_log import PsaPostLog
from app.schemas.flowpilot_analytics import (
FlowPilotDashboard,
MTTRDataPoint,
DomainBreakdown,
ConfidenceBreakdown,
KnowledgeCoverage,
DomainCoverage,
PsaMetrics,
)
from app.services.knowledge_gap_service import get_knowledge_gaps, KnowledgeGapReport
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/analytics/flowpilot", tags=["flowpilot-analytics"])
def _get_period_start(period: str) -> datetime:
days = {"7d": 7, "30d": 30, "90d": 90}.get(period, 30)
return datetime.now(timezone.utc) - timedelta(days=days)
@router.get("", response_model=FlowPilotDashboard)
@limiter.limit("15/minute")
async def get_dashboard(
request: Request,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_team_admin),
period: str = Query("30d", pattern="^(7d|30d|90d)$"),
):
"""Get FlowPilot analytics dashboard data."""
if not current_user.account_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No account")
account_id = current_user.account_id
period_start = _get_period_start(period)
# ── Session counts ──
counts_result = await db.execute(
select(
func.count(AISession.id).label("total"),
func.sum(case((AISession.status == "resolved", 1), else_=0)).label("resolved"),
func.sum(case((AISession.status.in_(["escalated", "requesting_escalation"]), 1), else_=0)).label("escalated"),
func.sum(case((AISession.status == "abandoned", 1), else_=0)).label("abandoned"),
func.avg(case((AISession.status == "resolved", AISession.step_count), else_=None)).label("avg_steps"),
func.avg(AISession.session_rating).label("avg_rating"),
)
.where(
AISession.account_id == account_id,
AISession.created_at >= period_start,
)
)
row = counts_result.one()
total = row.total or 0
resolved = row.resolved or 0
escalated = row.escalated or 0
abandoned = row.abandoned or 0
avg_steps = float(row.avg_steps or 0)
avg_rating = float(row.avg_rating) if row.avg_rating else None
resolution_rate = (resolved / total * 100) if total > 0 else 0.0
# ── MTTR ──
mttr_result = await db.execute(
select(
func.avg(
extract("epoch", AISession.resolved_at - AISession.created_at) / 60
).label("avg_mttr"),
)
.where(
AISession.account_id == account_id,
AISession.created_at >= period_start,
AISession.status == "resolved",
AISession.resolved_at.isnot(None),
)
)
mttr_row = mttr_result.one()
mttr_minutes = float(mttr_row.avg_mttr) if mttr_row.avg_mttr else None
# ── Average duration ──
duration_result = await db.execute(
select(
func.avg(
extract("epoch", AISession.resolved_at - AISession.created_at) / 60
).label("avg_duration"),
)
.where(
AISession.account_id == account_id,
AISession.created_at >= period_start,
AISession.resolved_at.isnot(None),
)
)
dur_row = duration_result.one()
avg_duration = float(dur_row.avg_duration) if dur_row.avg_duration else 0.0
# ── MTTR trend ──
mttr_trend_result = await db.execute(
select(
cast(AISession.resolved_at, Date).label("day"),
func.avg(
extract("epoch", AISession.resolved_at - AISession.created_at) / 60
).label("mttr"),
func.count(AISession.id).label("count"),
)
.where(
AISession.account_id == account_id,
AISession.created_at >= period_start,
AISession.status == "resolved",
AISession.resolved_at.isnot(None),
)
.group_by(cast(AISession.resolved_at, Date))
.order_by(cast(AISession.resolved_at, Date))
)
mttr_trend = [
MTTRDataPoint(
date=str(r.day),
mttr_minutes=round(float(r.mttr or 0), 1),
session_count=r.count,
)
for r in mttr_trend_result.all()
]
# ── Domain breakdown ──
domain_result = await db.execute(
select(
AISession.problem_domain,
func.count(AISession.id).label("total"),
func.sum(case((AISession.status == "resolved", 1), else_=0)).label("resolved"),
func.sum(case((AISession.status.in_(["escalated", "requesting_escalation"]), 1), else_=0)).label("escalated"),
)
.where(
AISession.account_id == account_id,
AISession.created_at >= period_start,
AISession.problem_domain.isnot(None),
)
.group_by(AISession.problem_domain)
.order_by(func.count(AISession.id).desc())
)
sessions_by_domain = [
DomainBreakdown(
domain=r.problem_domain or "unknown",
total=r.total,
resolved=r.resolved or 0,
escalated=r.escalated or 0,
resolution_rate=round((r.resolved or 0) / r.total * 100, 1) if r.total > 0 else 0.0,
)
for r in domain_result.all()
]
# ── Confidence breakdown ──
confidence_result = await db.execute(
select(
AISession.confidence_tier,
func.count(AISession.id).label("total"),
func.sum(case((AISession.status == "resolved", 1), else_=0)).label("resolved"),
)
.where(
AISession.account_id == account_id,
AISession.created_at >= period_start,
AISession.status.in_(["resolved", "escalated", "requesting_escalation"]),
)
.group_by(AISession.confidence_tier)
)
conf_data = {r.confidence_tier: (r.total or 0, r.resolved or 0) for r in confidence_result.all()}
guided_total, guided_resolved = conf_data.get("guided", (0, 0))
exploring_total, exploring_resolved = conf_data.get("exploring", (0, 0))
discovery_total, discovery_resolved = conf_data.get("discovery", (0, 0))
confidence_breakdown = ConfidenceBreakdown(
guided_sessions=guided_total,
guided_resolution_rate=round(guided_resolved / guided_total * 100, 1) if guided_total > 0 else 0.0,
exploring_sessions=exploring_total,
exploring_resolution_rate=round(exploring_resolved / exploring_total * 100, 1) if exploring_total > 0 else 0.0,
discovery_sessions=discovery_total,
discovery_resolution_rate=round(discovery_resolved / discovery_total * 100, 1) if discovery_total > 0 else 0.0,
)
# ── Knowledge coverage ──
total_flows_result = await db.execute(
select(func.count(Tree.id)).where(Tree.account_id == account_id)
)
total_flows = total_flows_result.scalar() or 0
ai_flows_result = await db.execute(
select(func.count(Tree.id)).where(
Tree.account_id == account_id,
Tree.origin.in_(["ai_generated", "ai_enhanced"]),
)
)
ai_generated_flows = ai_flows_result.scalar() or 0
pending_proposals_result = await db.execute(
select(func.count(FlowProposal.id)).where(
FlowProposal.account_id == account_id,
FlowProposal.status == "pending",
)
)
total_proposals_pending = pending_proposals_result.scalar() or 0
approved_result = await db.execute(
select(func.count(FlowProposal.id)).where(
FlowProposal.account_id == account_id,
FlowProposal.reviewed_at >= period_start,
FlowProposal.status.in_(["approved", "modified"]),
)
)
proposals_approved = approved_result.scalar() or 0
rejected_result = await db.execute(
select(func.count(FlowProposal.id)).where(
FlowProposal.account_id == account_id,
FlowProposal.reviewed_at >= period_start,
FlowProposal.status == "rejected",
)
)
proposals_rejected = rejected_result.scalar() or 0
# Domain coverage
domain_coverage_result = await db.execute(
select(
AISession.problem_domain,
func.count(AISession.id).label("session_count"),
func.sum(case((AISession.confidence_tier == "guided", 1), else_=0)).label("guided_count"),
)
.where(
AISession.account_id == account_id,
AISession.created_at >= period_start,
AISession.problem_domain.isnot(None),
)
.group_by(AISession.problem_domain)
)
domain_flow_counts_result = await db.execute(
select(
Tree.tree_type, # Reuse as domain proxy — not ideal but workable
func.count(Tree.id),
)
.where(Tree.account_id == account_id)
.group_by(Tree.tree_type)
)
# For now, flow_count per domain isn't directly available since Tree doesn't have problem_domain.
# Use match_keywords or just report 0. We'll improve this in Phase 4 with better flow categorization.
domain_cov_data = {}
for r in domain_coverage_result.all():
domain = r.problem_domain or "unknown"
sc = r.session_count or 0
gc = r.guided_count or 0
domain_cov_data[domain] = DomainCoverage(
domain=domain,
flow_count=0, # TODO: match via category/tags in Phase 4
session_count=sc,
guided_rate=round(gc / sc * 100, 1) if sc > 0 else 0.0,
)
knowledge_coverage = KnowledgeCoverage(
total_flows=total_flows,
ai_generated_flows=ai_generated_flows,
total_proposals_pending=total_proposals_pending,
proposals_approved_this_period=proposals_approved,
proposals_rejected_this_period=proposals_rejected,
coverage_by_domain=list(domain_cov_data.values()),
)
# ── PSA metrics ──
psa_metrics = None
psa_linked = await db.execute(
select(func.count(AISession.id)).where(
AISession.account_id == account_id,
AISession.created_at >= period_start,
AISession.psa_ticket_id.isnot(None),
)
)
psa_linked_count = psa_linked.scalar() or 0
if psa_linked_count > 0 and total > 0:
psa_push_result = await db.execute(
select(
func.count(PsaPostLog.id).label("total_pushes"),
func.sum(case((PsaPostLog.status == "success", 1), else_=0)).label("first_success"),
func.sum(case(
((PsaPostLog.status == "success") & (PsaPostLog.retry_count > 0), 1),
else_=0
)).label("retry_success"),
)
.join(AISession, PsaPostLog.ai_session_id == AISession.id)
.where(
AISession.account_id == account_id,
PsaPostLog.ai_session_id.isnot(None),
PsaPostLog.posted_at >= period_start,
)
)
push_row = psa_push_result.one()
total_pushes = push_row.total_pushes or 0
first_success = push_row.first_success or 0
retry_success = push_row.retry_success or 0
psa_metrics = PsaMetrics(
ticket_link_rate=round(psa_linked_count / total * 100, 1),
auto_push_success_rate=round(first_success / total_pushes * 100, 1) if total_pushes > 0 else 0.0,
auto_push_retry_success_rate=round(retry_success / total_pushes * 100, 1) if total_pushes > 0 else 0.0,
total_time_entries_logged=0, # TODO: track from CW time entries
total_hours_logged=0.0,
)
return FlowPilotDashboard(
period=period,
total_sessions=total,
resolved_sessions=resolved,
escalated_sessions=escalated,
abandoned_sessions=abandoned,
resolution_rate=round(resolution_rate, 1),
avg_steps_to_resolution=round(avg_steps, 1),
avg_session_duration_minutes=round(avg_duration, 1),
avg_rating=round(avg_rating, 2) if avg_rating else None,
mttr_minutes=round(mttr_minutes, 1) if mttr_minutes else None,
mttr_trend=mttr_trend,
sessions_by_domain=sessions_by_domain,
confidence_breakdown=confidence_breakdown,
knowledge_coverage=knowledge_coverage,
psa_metrics=psa_metrics,
)
@router.get("/knowledge-gaps", response_model=KnowledgeGapReport)
@limiter.limit("10/minute")
async def get_knowledge_gaps_endpoint(
request: Request,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_team_admin),
period: str = Query("30d", pattern="^(7d|30d|90d)$"),
):
"""Get knowledge gap analysis report."""
if not current_user.account_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No account")
days = {"7d": 7, "30d": 30, "90d": 90}.get(period, 30)
return await get_knowledge_gaps(current_user.account_id, db, period_days=days)

View File

@@ -355,6 +355,7 @@ async def generate_script(
user_id=current_user.id,
team_id=current_user.team_id,
session_id=data.session_id,
ai_session_id=data.ai_session_id,
parameters_used=redacted_params,
generated_script=rendered_script,
)

View File

@@ -22,6 +22,8 @@ from app.api.endpoints import onboarding
from app.api.endpoints import branding
from app.api.endpoints import supporting_data
from app.api.endpoints import ai_sessions
from app.api.endpoints import flow_proposals
from app.api.endpoints import flowpilot_analytics
api_router = APIRouter()
@@ -69,3 +71,5 @@ api_router.include_router(onboarding.router)
api_router.include_router(branding.router)
api_router.include_router(supporting_data.router)
api_router.include_router(ai_sessions.router)
api_router.include_router(flow_proposals.router)
api_router.include_router(flowpilot_analytics.router)