feat: FlowPilot AI — Phases 4 & 5 (Gallery, Export, Responsive, Enterprise, Analytics) #116

Merged
chihlasm merged 66 commits from feat/flowpilot-ai-session into main 2026-03-21 05:15:51 +00:00
3 changed files with 173 additions and 0 deletions
Showing only changes of commit a567d6d245 - Show all commits

View File

@@ -18,6 +18,7 @@ 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_activity_log import PsaActivityLog
from app.models.psa_post_log import PsaPostLog
from app.models.category import TreeCategory
from app.schemas.flowpilot_analytics import (
@@ -32,6 +33,9 @@ from app.schemas.flowpilot_analytics import (
CoverageResponse,
FlowQualityRow,
FlowQualityResponse,
EnhancedPsaMetrics,
PsaFunnel,
PsaDailyTrend,
)
from app.services.knowledge_gap_service import get_knowledge_gaps, KnowledgeGapReport
@@ -613,3 +617,121 @@ async def get_flow_quality(
top_performers=top_performers,
needs_attention=needs_attention,
)
@router.get("/psa-metrics", response_model=EnhancedPsaMetrics)
@limiter.limit("15/minute")
async def get_enhanced_psa_metrics(
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 enhanced PSA integration metrics including time entry stats, push funnel, and daily trend."""
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)
# ── Time entry totals from psa_activity_logs ──
time_entry_result = await db.execute(
select(
func.count(PsaActivityLog.id).label("entry_count"),
func.sum(PsaActivityLog.hours_logged).label("total_hours"),
)
.where(
PsaActivityLog.account_id == account_id,
PsaActivityLog.activity_type == "time_entry_posted",
PsaActivityLog.created_at >= period_start,
)
)
te_row = time_entry_result.one()
total_time_entries = int(te_row.entry_count or 0)
total_hours_raw = te_row.total_hours or 0
total_hours_logged = round(float(total_hours_raw), 2)
avg_hours = round(total_hours_logged / total_time_entries, 2) if total_time_entries > 0 else 0.0
# ── Push funnel ──
# Total sessions in period
total_sessions_result = await db.execute(
select(func.count(AISession.id)).where(
AISession.account_id == account_id,
AISession.created_at >= period_start,
)
)
total_sessions = int(total_sessions_result.scalar() or 0)
# Sessions linked to a ticket
linked_result = 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),
)
)
linked_to_ticket = int(linked_result.scalar() or 0)
# Sessions with a successful doc push (via PsaPostLog)
doc_pushed_result = await db.execute(
select(func.count(PsaPostLog.id.distinct()))
.join(AISession, PsaPostLog.ai_session_id == AISession.id)
.where(
AISession.account_id == account_id,
PsaPostLog.ai_session_id.isnot(None),
PsaPostLog.status == "success",
PsaPostLog.posted_at >= period_start,
)
)
doc_pushed = int(doc_pushed_result.scalar() or 0)
# Sessions with a time entry logged (via psa_activity_logs)
time_entry_sessions_result = await db.execute(
select(func.count(PsaActivityLog.session_id.distinct())).where(
PsaActivityLog.account_id == account_id,
PsaActivityLog.activity_type == "time_entry_posted",
PsaActivityLog.created_at >= period_start,
PsaActivityLog.session_id.isnot(None),
)
)
time_entry_logged = int(time_entry_sessions_result.scalar() or 0)
push_funnel = PsaFunnel(
total_sessions=total_sessions,
linked_to_ticket=linked_to_ticket,
doc_pushed=doc_pushed,
time_entry_logged=time_entry_logged,
)
# ── Daily trend (time entries grouped by date) ──
daily_result = await db.execute(
select(
cast(PsaActivityLog.created_at, Date).label("day"),
func.count(PsaActivityLog.id).label("entries"),
func.sum(PsaActivityLog.hours_logged).label("hours"),
)
.where(
PsaActivityLog.account_id == account_id,
PsaActivityLog.activity_type == "time_entry_posted",
PsaActivityLog.created_at >= period_start,
)
.group_by(cast(PsaActivityLog.created_at, Date))
.order_by(cast(PsaActivityLog.created_at, Date))
)
daily_trend = [
PsaDailyTrend(
date=str(r.day),
entries=int(r.entries or 0),
hours=round(float(r.hours or 0), 2),
)
for r in daily_result.all()
]
return EnhancedPsaMetrics(
total_time_entries=total_time_entries,
total_hours_logged=total_hours_logged,
avg_hours_per_session=avg_hours,
push_funnel=push_funnel,
daily_trend=daily_trend,
)

View File

@@ -87,6 +87,27 @@ class FlowQualityResponse(BaseModel):
needs_attention: list[FlowQualityRow]
class PsaFunnel(BaseModel):
total_sessions: int
linked_to_ticket: int
doc_pushed: int
time_entry_logged: int
class PsaDailyTrend(BaseModel):
date: str
entries: int
hours: float
class EnhancedPsaMetrics(BaseModel):
total_time_entries: int
total_hours_logged: float
avg_hours_per_session: float
push_funnel: PsaFunnel
daily_trend: list[PsaDailyTrend]
class FlowPilotDashboard(BaseModel):
period: str
total_sessions: int

View File

@@ -14,6 +14,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.ai_session import AISession
from app.models.psa_activity_log import PsaActivityLog
from app.models.psa_connection import PsaConnection
from app.models.psa_member_mapping import PsaMemberMapping
from app.models.psa_post_log import PsaPostLog
@@ -303,6 +304,7 @@ async def push_documentation(
)
# Create time entry if member mapping exists
time_entry_hours: Optional[float] = None
if member_mapping and session.resolved_at and session.created_at:
try:
delta = session.resolved_at - session.created_at
@@ -316,10 +318,38 @@ async def push_documentation(
hours=rounded_hours,
notes=f"FlowPilot session: {session.problem_summary or 'Troubleshooting'}",
)
time_entry_hours = rounded_hours
except Exception as e:
logger.warning("Failed to create time entry for session %s: %s", session.id, e)
# Don't fail the note push just because time entry failed
# Log PSA activity — note posted
try:
note_activity = PsaActivityLog(
account_id=session.account_id,
session_id=session.id,
activity_type="note_posted",
hours_logged=None,
psa_ticket_id=session.psa_ticket_id,
)
db.add(note_activity)
except Exception as e:
logger.warning("Failed to log PSA note activity for session %s: %s", session.id, e)
# Log time entry activity if one was created
if time_entry_hours is not None:
try:
time_activity = PsaActivityLog(
account_id=session.account_id,
session_id=session.id,
activity_type="time_entry_posted",
hours_logged=time_entry_hours,
psa_ticket_id=session.psa_ticket_id,
)
db.add(time_activity)
except Exception as e:
logger.warning("Failed to log PSA time entry activity for session %s: %s", session.id, e)
# Log success
log_entry = PsaPostLog(
id=uuid.uuid4(),