From 61adf63c7d07662fadb471fe8419f73d015c93a4 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Thu, 21 May 2026 19:31:21 +0000 Subject: [PATCH] feat: add Alembic migration for Meet Kevin tables --- .../c3d4e5f6a7b8_add_meet_kevin_tables.py | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 alembic/versions/c3d4e5f6a7b8_add_meet_kevin_tables.py diff --git a/alembic/versions/c3d4e5f6a7b8_add_meet_kevin_tables.py b/alembic/versions/c3d4e5f6a7b8_add_meet_kevin_tables.py new file mode 100644 index 0000000..6b7ad22 --- /dev/null +++ b/alembic/versions/c3d4e5f6a7b8_add_meet_kevin_tables.py @@ -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)