241 lines
8.4 KiB
Python
241 lines
8.4 KiB
Python
"""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"),
|
|
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"), 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"), 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"), nullable=False
|
|
)
|
|
conviction: Mapped[Decimal] = mapped_column(Numeric(4, 3), nullable=False)
|
|
time_horizon: Mapped[KevinTimeHorizon] = mapped_column(
|
|
SAEnum(KevinTimeHorizon, name="kevin_time_horizon"), 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")
|