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:
2026-03-20 00:11:07 +00:00
parent 7ec626f45a
commit a567d6d245
3 changed files with 173 additions and 0 deletions

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,
)