From b5e034856fcef2b3c9303f23ca1eafa252161858 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 16 Feb 2026 00:18:42 -0500 Subject: [PATCH] feat: add SessionRating model and analytics schemas Co-Authored-By: Claude Opus 4.6 --- backend/app/models/__init__.py | 2 + backend/app/models/session_rating.py | 46 +++++++++++ backend/app/schemas/analytics.py | 111 +++++++++++++++++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 backend/app/models/session_rating.py create mode 100644 backend/app/schemas/analytics.py diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 7ba8536d..674f7627 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -17,6 +17,7 @@ from .refresh_token import RefreshToken from .audit_log import AuditLog from .password_reset_token import PasswordResetToken from .session_share import SessionShare, SessionShareView +from .session_rating import SessionRating from .account_limit_override import AccountLimitOverride from .feature_flag import FeatureFlag, PlanFeatureDefault, AccountFeatureOverride from .platform_setting import PlatformSetting @@ -47,6 +48,7 @@ __all__ = [ "PasswordResetToken", "SessionShare", "SessionShareView", + "SessionRating", "AccountLimitOverride", "FeatureFlag", "PlanFeatureDefault", diff --git a/backend/app/models/session_rating.py b/backend/app/models/session_rating.py new file mode 100644 index 00000000..63d196be --- /dev/null +++ b/backend/app/models/session_rating.py @@ -0,0 +1,46 @@ +import uuid +from datetime import datetime, timezone +from typing import Optional, TYPE_CHECKING +from sqlalchemy import String, DateTime, Integer, CheckConstraint, UniqueConstraint, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID +from app.core.database import Base + +if TYPE_CHECKING: + from app.models.user import User + from app.models.session import Session + from app.models.tree import Tree + + +class SessionRating(Base): + __tablename__ = "session_ratings" + __table_args__ = ( + CheckConstraint("rating >= 1 AND rating <= 5", name="ck_session_ratings_rating_range"), + UniqueConstraint("session_id", name="uq_session_ratings_session_id"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + session_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("sessions.id", ondelete="CASCADE"), nullable=False + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + tree_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("trees.id", ondelete="CASCADE"), nullable=False + ) + account_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False + ) + rating: Mapped[int] = mapped_column(Integer, nullable=False) + comment: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False + ) + + # Relationships + session: Mapped["Session"] = relationship("Session", foreign_keys=[session_id]) + user: Mapped["User"] = relationship("User", foreign_keys=[user_id]) + tree: Mapped["Tree"] = relationship("Tree", foreign_keys=[tree_id]) diff --git a/backend/app/schemas/analytics.py b/backend/app/schemas/analytics.py new file mode 100644 index 00000000..0cb9651f --- /dev/null +++ b/backend/app/schemas/analytics.py @@ -0,0 +1,111 @@ +from datetime import datetime +from typing import Optional +from pydantic import BaseModel, Field + + +# --- Session Rating Schemas --- + +class SessionRatingCreate(BaseModel): + rating: int = Field(..., ge=1, le=5) + comment: Optional[str] = Field(None, max_length=500) + + +class SessionRatingResponse(BaseModel): + id: str + session_id: str + rating: int + comment: Optional[str] + created_at: datetime + + model_config = {"from_attributes": True} + + +class FlowRatingItem(BaseModel): + """Anonymous feedback item — no user_name for privacy.""" + rating: int + comment: Optional[str] + created_at: datetime + + +# --- Step Feedback Schema --- + +class StepFeedbackCreate(BaseModel): + session_id: str + was_helpful: bool + + +# --- Analytics Response Schemas --- + +class OutcomeBreakdown(BaseModel): + resolved: int = 0 + escalated: int = 0 + workaround: int = 0 + unresolved: int = 0 + + +class AnalyticsSummary(BaseModel): + total_sessions: int + completed_sessions: int + completion_rate: float + median_duration_minutes: float + active_engineers: int = 0 + outcome_breakdown: OutcomeBreakdown + + +class TimeSeriesPoint(BaseModel): + date: str + sessions: int = 0 + resolved: int = 0 + escalated: int = 0 + workaround: int = 0 + unresolved: int = 0 + + +class TopFlow(BaseModel): + tree_id: str + name: str + sessions: int + completion_rate: float + median_duration_minutes: float + avg_csat: Optional[float] = None + + +class TopEngineer(BaseModel): + user_id: str + name: str + sessions: int + completion_rate: float + median_duration_minutes: float + + +class TeamAnalyticsResponse(BaseModel): + summary: AnalyticsSummary + time_series: list[TimeSeriesPoint] + top_flows: list[TopFlow] + top_engineers: list[TopEngineer] + + +class PersonalAnalyticsResponse(BaseModel): + summary: AnalyticsSummary + time_series: list[TimeSeriesPoint] + top_flows: list[TopFlow] + + +class StepFeedbackSummary(BaseModel): + node_id: str + node_title: str + helpful_yes: int + helpful_no: int + helpful_rate: float + visit_count: int = 0 + dropoff_count: int = 0 + dropoff_rate: float = 0.0 + + +class FlowAnalyticsResponse(BaseModel): + summary: AnalyticsSummary + avg_csat: Optional[float] + total_ratings: int + time_series: list[TimeSeriesPoint] + step_feedback: list[StepFeedbackSummary] + recent_comments: list[FlowRatingItem]