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:
@@ -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:
|
||||
|
||||
306
backend/app/api/endpoints/flow_proposals.py
Normal file
306
backend/app/api/endpoints/flow_proposals.py
Normal 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
|
||||
358
backend/app/api/endpoints/flowpilot_analytics.py
Normal file
358
backend/app/api/endpoints/flowpilot_analytics.py
Normal 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)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user