"""Meet Kevin YouTube channel models: Channel, Video, Transcript, Analysis, StockMention.""" import enum from datetime import datetime from decimal import Decimal from sqlalchemy import ( BigInteger, Boolean, DateTime, Enum as SAEnum, ForeignKey, Integer, Numeric, String, Text, func, ) from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship from shared.models.base import Base, TimestampMixin # --------------------------------------------------------------------------- # Enums # --------------------------------------------------------------------------- class KevinVideoStatus(str, enum.Enum): """Video processing status in the pipeline.""" DISCOVERED = "discovered" CAPTIONED = "captioned" ANALYZED = "analyzed" FAILED = "failed" SKIPPED = "skipped" class KevinTranscriptSource(str, enum.Enum): """Source of transcript captions.""" CAPTIONS_MANUAL = "captions_manual" CAPTIONS_AUTO = "captions_auto" NONE = "none" class KevinMarketOutlook(str, enum.Enum): """Kevin's overall market direction sentiment.""" BULLISH = "bullish" NEUTRAL = "neutral" BEARISH = "bearish" MIXED = "mixed" class KevinTickerAction(str, enum.Enum): """Recommended action on a ticker.""" BUY = "buy" SELL = "sell" HOLD = "hold" WATCH = "watch" AVOID = "avoid" class KevinTimeHorizon(str, enum.Enum): """Time horizon for a recommendation.""" INTRADAY = "intraday" DAYS = "days" WEEKS = "weeks" MONTHS = "months" LONG_TERM = "long_term" UNSPECIFIED = "unspecified" # --------------------------------------------------------------------------- # Models # --------------------------------------------------------------------------- class KevinChannel(TimestampMixin, Base): """YouTube channel configuration and polling metadata.""" __tablename__ = "kevin_channels" id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) youtube_channel_id: Mapped[str] = mapped_column( String(255), unique=True, nullable=False, index=True ) title: Mapped[str] = mapped_column(String(255), nullable=False) poll_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) poll_interval_seconds: Mapped[int] = mapped_column(Integer, default=10800) daily_cost_cap_usd: Mapped[Decimal] = mapped_column( Numeric(8, 2), default=Decimal("5.00") ) last_polled_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) # Relationships videos: Mapped[list["KevinVideo"]] = relationship( back_populates="channel", cascade="all, delete-orphan" ) class KevinVideo(TimestampMixin, Base): """YouTube video metadata and processing status.""" __tablename__ = "kevin_videos" id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) channel_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("kevin_channels.id"), nullable=False, index=True ) youtube_video_id: Mapped[str] = mapped_column( String(255), unique=True, nullable=False, index=True ) title: Mapped[str] = mapped_column(String(500), nullable=False) description: Mapped[str | None] = mapped_column(Text, nullable=True) published_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True, index=True ) duration_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True) thumbnail_url: Mapped[str | None] = mapped_column(Text, nullable=True) status: Mapped[KevinVideoStatus] = mapped_column( SAEnum(KevinVideoStatus, name="kevin_video_status", values_callable=lambda x: [e.value for e in x]), nullable=False, default=KevinVideoStatus.DISCOVERED, index=True, ) failure_reason: Mapped[str | None] = mapped_column(String(500), nullable=True) retry_count: Mapped[int] = mapped_column(Integer, default=0) processed_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) # Relationships channel: Mapped[KevinChannel] = relationship(back_populates="videos") transcript: Mapped["KevinTranscript | None"] = relationship( back_populates="video", uselist=False, cascade="all, delete-orphan" ) analyses: Mapped[list["KevinAnalysis"]] = relationship( back_populates="video", cascade="all, delete-orphan" ) mentions: Mapped[list["KevinStockMention"]] = relationship( back_populates="video", cascade="all, delete-orphan" ) class KevinTranscript(Base): """Extracted transcript from video captions.""" __tablename__ = "kevin_transcripts" id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) video_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("kevin_videos.id"), unique=True, nullable=False ) source: Mapped[KevinTranscriptSource] = mapped_column( SAEnum(KevinTranscriptSource, name="kevin_transcript_source", values_callable=lambda x: [e.value for e in x]), nullable=False ) language: Mapped[str] = mapped_column(String(8), nullable=False) raw_text: Mapped[str] = mapped_column(Text, nullable=False) segments_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True) word_count: Mapped[int] = mapped_column(Integer, nullable=False) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) # Relationships video: Mapped[KevinVideo] = relationship(back_populates="transcript") class KevinAnalysis(Base): """LLM analysis result for a video.""" __tablename__ = "kevin_analyses" id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) video_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("kevin_videos.id"), nullable=False, index=True ) model: Mapped[str] = mapped_column(String(100), nullable=False) prompt_version: Mapped[str] = mapped_column(String(50), nullable=False) market_outlook_direction: Mapped[KevinMarketOutlook] = mapped_column( SAEnum(KevinMarketOutlook, name="kevin_market_outlook", values_callable=lambda x: [e.value for e in x]), nullable=False ) market_outlook_reasoning: Mapped[str] = mapped_column(Text, nullable=False) macro_themes_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True) key_risks_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True) summary: Mapped[str] = mapped_column(Text, nullable=False) raw_response_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True) prompt_tokens: Mapped[int] = mapped_column(Integer, nullable=False) completion_tokens: Mapped[int] = mapped_column(Integer, nullable=False) cost_usd: Mapped[Decimal] = mapped_column(Numeric(10, 4), nullable=False) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) # Relationships video: Mapped[KevinVideo] = relationship(back_populates="analyses") mentions: Mapped[list["KevinStockMention"]] = relationship( back_populates="analysis", cascade="all, delete-orphan" ) class KevinStockMention(Base): """Per-ticker recommendation extracted from analysis.""" __tablename__ = "kevin_stock_mentions" id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) video_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("kevin_videos.id"), nullable=False, index=True ) analysis_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("kevin_analyses.id"), nullable=False ) symbol: Mapped[str] = mapped_column( String(16), nullable=False, index=True ) action: Mapped[KevinTickerAction] = mapped_column( SAEnum(KevinTickerAction, name="kevin_ticker_action", values_callable=lambda x: [e.value for e in x]), nullable=False ) conviction: Mapped[Decimal] = mapped_column(Numeric(4, 3), nullable=False) time_horizon: Mapped[KevinTimeHorizon] = mapped_column( SAEnum(KevinTimeHorizon, name="kevin_time_horizon", values_callable=lambda x: [e.value for e in x]), nullable=False ) rationale_quote: Mapped[str] = mapped_column(Text, nullable=False) video_timestamp_seconds: Mapped[int | None] = mapped_column( Integer, nullable=True ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) # Relationships video: Mapped[KevinVideo] = relationship(back_populates="mentions") analysis: Mapped[KevinAnalysis] = relationship(back_populates="mentions")