feat: add Alembic migration for Meet Kevin tables

This commit is contained in:
Viktor Barzin 2026-05-21 19:31:21 +00:00
parent a49e46f787
commit 61adf63c7d

View file

@ -0,0 +1,144 @@
"""add Meet Kevin tables
Revision ID: c3d4e5f6a7b8
Revises: b2c3d4e5f6a7
Create Date: 2026-05-21 10:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "c3d4e5f6a7b8"
down_revision: Union[str, Sequence[str], None] = "b2c3d4e5f6a7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create the 5 Meet Kevin tables with enums, indexes, and seed data."""
# Create kevin_channels table
op.create_table(
"kevin_channels",
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
sa.Column("youtube_channel_id", sa.String(255), nullable=False, unique=True, index=True),
sa.Column("title", sa.String(255), nullable=False),
sa.Column("poll_enabled", sa.Boolean, nullable=False, server_default="true"),
sa.Column("poll_interval_seconds", sa.Integer, nullable=False, server_default="10800"),
sa.Column("daily_cost_cap_usd", sa.Numeric(8, 2), nullable=False, server_default="5.00"),
sa.Column("last_polled_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
# Create kevin_videos table
op.create_table(
"kevin_videos",
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
sa.Column("channel_id", sa.BigInteger, sa.ForeignKey("kevin_channels.id"), nullable=False, index=True),
sa.Column("youtube_video_id", sa.String(255), nullable=False, unique=True, index=True),
sa.Column("title", sa.String(500), nullable=False),
sa.Column("description", sa.Text, nullable=True),
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True, index=True),
sa.Column("duration_seconds", sa.Integer, nullable=True),
sa.Column("thumbnail_url", sa.Text, nullable=True),
sa.Column("status", sa.Enum("discovered", "captioned", "analyzed", "failed", "skipped", name="kevin_video_status"), nullable=False, server_default="discovered", index=True),
sa.Column("failure_reason", sa.String(500), nullable=True),
sa.Column("retry_count", sa.Integer, nullable=False, server_default="0"),
sa.Column("processed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
# Create kevin_transcripts table
op.create_table(
"kevin_transcripts",
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
sa.Column("video_id", sa.BigInteger, sa.ForeignKey("kevin_videos.id"), nullable=False, unique=True),
sa.Column("source", sa.Enum("captions_manual", "captions_auto", "none", name="kevin_transcript_source"), nullable=False),
sa.Column("language", sa.String(8), nullable=False),
sa.Column("raw_text", sa.Text, nullable=False),
sa.Column("segments_json", postgresql.JSONB, nullable=True),
sa.Column("word_count", sa.Integer, nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
# Create kevin_analyses table
op.create_table(
"kevin_analyses",
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
sa.Column("video_id", sa.BigInteger, sa.ForeignKey("kevin_videos.id"), nullable=False, index=True),
sa.Column("model", sa.String(100), nullable=False),
sa.Column("prompt_version", sa.String(50), nullable=False),
sa.Column("market_outlook_direction", sa.Enum("bullish", "neutral", "bearish", "mixed", name="kevin_market_outlook"), nullable=False),
sa.Column("market_outlook_reasoning", sa.Text, nullable=False),
sa.Column("macro_themes_json", postgresql.JSONB, nullable=True),
sa.Column("key_risks_json", postgresql.JSONB, nullable=True),
sa.Column("summary", sa.Text, nullable=False),
sa.Column("raw_response_json", postgresql.JSONB, nullable=True),
sa.Column("prompt_tokens", sa.Integer, nullable=False),
sa.Column("completion_tokens", sa.Integer, nullable=False),
sa.Column("cost_usd", sa.Numeric(10, 4), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
# Create kevin_stock_mentions table
op.create_table(
"kevin_stock_mentions",
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
sa.Column("video_id", sa.BigInteger, sa.ForeignKey("kevin_videos.id"), nullable=False, index=True),
sa.Column("analysis_id", sa.BigInteger, sa.ForeignKey("kevin_analyses.id"), nullable=False),
sa.Column("symbol", sa.String(16), nullable=False, index=True),
sa.Column("action", sa.Enum("buy", "sell", "hold", "watch", "avoid", name="kevin_ticker_action"), nullable=False),
sa.Column("conviction", sa.Numeric(4, 3), nullable=False),
sa.Column("time_horizon", sa.Enum("intraday", "days", "weeks", "months", "long_term", "unspecified", name="kevin_time_horizon"), nullable=False),
sa.Column("rationale_quote", sa.Text, nullable=False),
sa.Column("video_timestamp_seconds", sa.Integer, nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
# Create composite index on kevin_stock_mentions (symbol, created_at DESC)
op.create_index(
"ix_kevin_stock_mentions_symbol_created_at",
"kevin_stock_mentions",
["symbol", sa.text("created_at DESC")],
)
# Create partial index on kevin_videos (status) for pending videos
op.create_index(
"ix_kevin_videos_status_pending",
"kevin_videos",
["status"],
postgresql_where=sa.text("status IN ('discovered', 'captioned')"),
)
# Seed the Meet Kevin channel
op.execute(
"""
INSERT INTO kevin_channels (youtube_channel_id, title, poll_enabled, poll_interval_seconds, daily_cost_cap_usd)
VALUES ('UCUvvj5lwue7PspotMDjk5UA', 'Meet Kevin', true, 10800, 5.00)
ON CONFLICT (youtube_channel_id) DO NOTHING;
"""
)
def downgrade() -> None:
"""Drop all Meet Kevin tables and enum types."""
# Drop tables in reverse dependency order
op.drop_table("kevin_stock_mentions")
op.drop_table("kevin_analyses")
op.drop_table("kevin_transcripts")
op.drop_table("kevin_videos")
op.drop_table("kevin_channels")
# Drop enum types
sa.Enum(name="kevin_time_horizon").drop(op.get_bind(), checkfirst=False)
sa.Enum(name="kevin_ticker_action").drop(op.get_bind(), checkfirst=False)
sa.Enum(name="kevin_market_outlook").drop(op.get_bind(), checkfirst=False)
sa.Enum(name="kevin_transcript_source").drop(op.get_bind(), checkfirst=False)
sa.Enum(name="kevin_video_status").drop(op.get_bind(), checkfirst=False)