feat(analytics): add PSA activity logging and enhanced PSA metrics endpoint
- Log `note_posted` and `time_entry_posted` activities to `psa_activity_logs` after each successful PSA push in `psa_documentation_service.py`; errors are caught and logged without blocking the main push flow - Add `PsaFunnel`, `PsaDailyTrend`, and `EnhancedPsaMetrics` Pydantic schemas - Add `GET /analytics/flowpilot/psa-metrics?period=30d` endpoint (team_admin, rate-limited 15/min) returning time entry totals, push funnel (sessions → linked → doc pushed → time entry logged), and daily trend Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user