File size: 11,562 Bytes
c84fdae
 
 
 
 
 
 
 
 
2523f7f
c84fdae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f133f15
 
 
7a204bc
f133f15
7a204bc
 
 
 
 
 
c84fdae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6286ee1
c84fdae
 
149bfeb
6286ee1
 
 
 
 
149bfeb
6286ee1
 
 
149bfeb
 
c84fdae
149bfeb
 
 
c84fdae
149bfeb
6286ee1
 
 
149bfeb
 
c84fdae
149bfeb
 
 
c84fdae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
"""
Run ALTER TABLE ADD COLUMN IF NOT EXISTS for columns added after initial deploy.
Safe to run on every startup β€” IF NOT EXISTS is a no-op when column already exists.
"""

from loguru import logger

_VECTOR_MIGRATIONS = [
    "CREATE EXTENSION IF NOT EXISTS vector",
    # vector columns already exist in Supabase β€” ALTER TABLE causes statement timeout
]

_VECTOR_INDEXES = [
    "CREATE INDEX IF NOT EXISTS ix_posts_content_embedding ON posts USING ivfflat (content_embedding vector_cosine_ops) WITH (lists = 10)",
    "CREATE INDEX IF NOT EXISTS ix_trends_topic_embedding ON trends USING ivfflat (topic_embedding vector_cosine_ops) WITH (lists = 10)",
]

_MIGRATIONS = [
    # posts β€” columns added in post-MVP schema evolution
    "ALTER TABLE posts ADD COLUMN IF NOT EXISTS post_type VARCHAR DEFAULT 'image'",
    "ALTER TABLE posts ADD COLUMN IF NOT EXISTS carousel_urls JSON",
    "ALTER TABLE posts ADD COLUMN IF NOT EXISTS video_url VARCHAR",
    "ALTER TABLE posts ADD COLUMN IF NOT EXISTS link_url VARCHAR",
    "ALTER TABLE posts ADD COLUMN IF NOT EXISTS link_title VARCHAR",
    "ALTER TABLE posts ADD COLUMN IF NOT EXISTS link_description VARCHAR",
    "ALTER TABLE posts ADD COLUMN IF NOT EXISTS approval_status VARCHAR DEFAULT 'pending'",
    "ALTER TABLE posts ADD COLUMN IF NOT EXISTS approval_notes TEXT",
    "ALTER TABLE posts ADD COLUMN IF NOT EXISTS approved_by VARCHAR",
    "ALTER TABLE posts ADD COLUMN IF NOT EXISTS approved_at TIMESTAMP",
    "ALTER TABLE posts ADD COLUMN IF NOT EXISTS token_count INTEGER",
    "ALTER TABLE posts ADD COLUMN IF NOT EXISTS generation_cost FLOAT",
    "ALTER TABLE posts ADD COLUMN IF NOT EXISTS hallucination_score FLOAT",
    "ALTER TABLE posts ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP",
    # posts β€” generation_error for failed generating jobs
    "ALTER TABLE posts ADD COLUMN IF NOT EXISTS generation_error TEXT",
    # agent_versions β€” created_by added later
    "ALTER TABLE agent_versions ADD COLUMN IF NOT EXISTS created_by VARCHAR",
    # seed default agent and agent_version so posts can reference agent_version_id=1
    "INSERT INTO agents (id, name, is_active) SELECT 1, 'default', true WHERE NOT EXISTS (SELECT 1 FROM agents WHERE id = 1)",
    "INSERT INTO agent_versions (id, agent_id, version, config) SELECT 1, 1, 1, '{}'::json WHERE NOT EXISTS (SELECT 1 FROM agent_versions WHERE id = 1)",
    # workflow_executions β€” execution_metadata added later
    "ALTER TABLE workflow_executions ADD COLUMN IF NOT EXISTS execution_metadata JSON",
    """
    CREATE TABLE IF NOT EXISTS brand_voices (
        id SERIAL PRIMARY KEY,
        brand_name VARCHAR DEFAULT 'Orbis',
        voice TEXT,
        audience TEXT,
        tone_rules TEXT,
        banned_words TEXT,
        cta_rules TEXT,
        hashtag_rules TEXT,
        example_posts TEXT,
        is_active BOOLEAN DEFAULT TRUE,
        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
        updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    )
    """,
    "CREATE INDEX IF NOT EXISTS ix_brand_voice_active ON brand_voices(is_active)",
    # Performance indexes for common query patterns
    "CREATE INDEX IF NOT EXISTS ix_posts_trend_status ON posts(trend_id, status)",
    "CREATE INDEX IF NOT EXISTS ix_posts_scheduled ON posts(scheduled_at) WHERE scheduled_at IS NOT NULL",
    "CREATE INDEX IF NOT EXISTS ix_trends_status_score ON trends(status, score DESC)",
    "ALTER TABLE posts ADD COLUMN IF NOT EXISTS scheduled_at TIMESTAMP",
    # music tracks library
    """
    CREATE TABLE IF NOT EXISTS music_tracks (
        id SERIAL PRIMARY KEY,
        title VARCHAR NOT NULL,
        artist VARCHAR,
        genre VARCHAR,
        mood VARCHAR,
        bpm INTEGER,
        duration_secs INTEGER,
        preview_url VARCHAR,
        cover_url VARCHAR,
        tags JSON DEFAULT '[]',
        is_active BOOLEAN DEFAULT TRUE,
        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    )
    """,
    "ALTER TABLE posts ADD COLUMN IF NOT EXISTS music_id INTEGER REFERENCES music_tracks(id) ON DELETE SET NULL",
    # agent_findings β€” AI agent team daily analysis results
    """
    CREATE TABLE IF NOT EXISTS agent_findings (
        id SERIAL PRIMARY KEY,
        agent_role VARCHAR NOT NULL,
        category VARCHAR,
        severity VARCHAR DEFAULT 'info',
        title VARCHAR NOT NULL,
        body TEXT,
        action_items JSON DEFAULT '[]',
        data_snapshot JSON DEFAULT '{}',
        run_date DATE NOT NULL,
        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    )
    """,
    "CREATE INDEX IF NOT EXISTS ix_agent_findings_run_date ON agent_findings(run_date)",
    "CREATE INDEX IF NOT EXISTS ix_agent_findings_agent_role ON agent_findings(agent_role)",
    "CREATE INDEX IF NOT EXISTS ix_agent_findings_severity ON agent_findings(severity)",
    # post quality scoring
    "ALTER TABLE posts ADD COLUMN IF NOT EXISTS quality_score INTEGER",
    "ALTER TABLE posts ADD COLUMN IF NOT EXISTS quality_breakdown JSON",
    # brand knowledge base
    """
    CREATE TABLE IF NOT EXISTS brand_knowledge (
        id SERIAL PRIMARY KEY,
        title VARCHAR NOT NULL,
        content TEXT NOT NULL,
        content_type VARCHAR DEFAULT 'general',
        embedding vector(1536),
        is_active BOOLEAN DEFAULT TRUE,
        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
        updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    )
    """,
    "CREATE INDEX IF NOT EXISTS ix_brand_knowledge_active ON brand_knowledge(is_active)",
    "CREATE INDEX IF NOT EXISTS ix_brand_knowledge_type ON brand_knowledge(content_type)",
    # provider_keys β€” encrypted LLM API keys manageable from Settings UI
    """
    CREATE TABLE IF NOT EXISTS provider_keys (
        id SERIAL PRIMARY KEY,
        provider VARCHAR NOT NULL UNIQUE,
        display_name VARCHAR,
        api_key_enc TEXT,
        api_base VARCHAR,
        is_enabled BOOLEAN DEFAULT TRUE,
        models JSON DEFAULT '[]',
        updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    )
    """,
    "ALTER TABLE provider_keys ADD COLUMN IF NOT EXISTS models JSON DEFAULT '[]'",
    "ALTER TABLE provider_keys ADD COLUMN IF NOT EXISTS disabled_models JSON DEFAULT '[]'",
    # brand_voices β€” auto hashtags + caption templates
    "ALTER TABLE brand_voices ADD COLUMN IF NOT EXISTS auto_hashtags TEXT",
    "ALTER TABLE brand_voices ADD COLUMN IF NOT EXISTS caption_templates JSON DEFAULT '[]'",
    # posts β€” monthly budget tracking + engagement metrics
    "ALTER TABLE posts ADD COLUMN IF NOT EXISTS budget_month VARCHAR(7)",
    "ALTER TABLE posts ADD COLUMN IF NOT EXISTS ig_likes INTEGER",
    "ALTER TABLE posts ADD COLUMN IF NOT EXISTS ig_comments INTEGER",
    "ALTER TABLE posts ADD COLUMN IF NOT EXISTS ig_reach INTEGER",
    "ALTER TABLE posts ADD COLUMN IF NOT EXISTS ig_saves INTEGER",
    "ALTER TABLE posts ADD COLUMN IF NOT EXISTS ig_shares INTEGER",
    "ALTER TABLE posts ADD COLUMN IF NOT EXISTS engagement_fetched_at TIMESTAMP",
]

_MUSIC_SEED = [
    ("Upbeat Rush", "Studio Orbit", "electronic", "energetic", 128, 180, '["workout","fitness","sport"]'),
    ("Power Drive", "Studio Orbit", "pop", "energetic", 135, 165, '["energy","hustle","motivation"]'),
    ("Momentum", "Studio Orbit", "electronic", "energetic", 130, 200, '["gym","run","power"]'),
    ("Morning Breath", "Studio Orbit", "ambient", "calm", 70, 210, '["meditation","yoga","peace"]'),
    ("Soft Focus", "Studio Orbit", "acoustic", "calm", 75, 195, '["relax","nature","serene"]'),
    ("Gentle Flow", "Studio Orbit", "ambient", "calm", 65, 225, '["wellness","mindful","breathe"]'),
    ("Summer Haze", "Studio Orbit", "pop", "upbeat", 112, 175, '["lifestyle","fashion","fun"]'),
    ("Golden Vibes", "Studio Orbit", "pop", "upbeat", 118, 185, '["trending","happy","style"]'),
    ("Fresh Start", "Studio Orbit", "pop", "upbeat", 110, 170, '["beauty","positive","joy"]'),
    ("Rise Above", "Studio Orbit", "cinematic", "inspirational", 90, 240, '["success","growth","goal"]'),
    ("New Chapter", "Studio Orbit", "cinematic", "inspirational", 85, 220, '["journey","dream","achieve"]'),
    ("Limitless", "Studio Orbit", "cinematic", "inspirational", 88, 235, '["startup","motivate","rise"]'),
    ("Digital Pulse", "Studio Orbit", "electronic", "modern", 130, 190, '["tech","ai","digital"]'),
    ("Future State", "Studio Orbit", "electronic", "modern", 125, 195, '["innovation","code","data"]'),
    ("Kitchen Stories", "Studio Orbit", "acoustic", "warm", 80, 185, '["food","cook","recipe"]'),
    ("Cozy Corner", "Studio Orbit", "acoustic", "warm", 72, 200, '["home","family","comfort"]'),
    ("Golden Hour", "Studio Orbit", "acoustic", "romantic", 72, 215, '["love","couple","romance"]'),
    ("Sweet Embrace", "Studio Orbit", "acoustic", "romantic", 68, 205, '["heart","wedding","together"]'),
    ("Smooth Edge", "Studio Orbit", "jazz", "sophisticated", 95, 195, '["luxury","premium","business"]'),
    ("Bounce", "Studio Orbit", "pop", "playful", 116, 160, '["funny","fun","play","kids"]'),
]


async def run_migrations():
    import time
    from src.utils.database import engine
    from sqlalchemy import text

    # Hard time budget β€” migrations are mostly no-ops on an established DB,
    # so if they hang past this we just move on and the server still starts.
    BUDGET_SECS = 60
    start = time.monotonic()

    for stmt in _VECTOR_MIGRATIONS:
        if time.monotonic() - start > BUDGET_SECS:
            logger.warning("[Migrate] Vector budget exceeded β€” skipping rest")
            break
        try:
            async with engine.begin() as conn:
                await conn.execute(text(stmt))
        except Exception as e:
            logger.warning(f"[Migrate] Vector stmt skipped: {e!s:.120}")
    logger.info("[Migrate] Vector extension and columns applied")

    for stmt in _MIGRATIONS:
        if time.monotonic() - start > BUDGET_SECS:
            logger.warning("[Migrate] Migration budget exceeded β€” assuming schema is current")
            break
        try:
            async with engine.begin() as conn:
                await conn.execute(text(stmt))
        except Exception as e:
            logger.warning(f"[Migrate] Stmt skipped (non-fatal): {e!s:.120}")
    logger.info("[Migrate] Column migrations applied")

    # ivfflat indexes require at least 1 row β€” best-effort
    try:
        async with engine.begin() as conn:
            for stmt in _VECTOR_INDEXES:
                await conn.execute(text(stmt))
        logger.info("[Migrate] Vector indexes applied")
    except Exception as e:
        logger.warning(f"[Migrate] Vector index creation skipped (table may be empty): {e}")

    await _seed_music_tracks()


async def _seed_music_tracks():
    from src.utils.database import engine
    from sqlalchemy import text
    try:
        async with engine.begin() as conn:
            count = (await conn.execute(text("SELECT COUNT(*) FROM music_tracks"))).scalar()
            if count == 0:
                for (title, artist, genre, mood, bpm, dur, tags) in _MUSIC_SEED:
                    await conn.execute(text(
                        "INSERT INTO music_tracks (title, artist, genre, mood, bpm, duration_secs, tags) "
                        "VALUES (:t, :a, :g, :m, :b, :d, CAST(:tg AS json))"
                    ), {"t": title, "a": artist, "g": genre, "m": mood, "b": bpm, "d": dur, "tg": tags})
                logger.info(f"[Migrate] Seeded {len(_MUSIC_SEED)} music tracks")
    except Exception as e:
        logger.warning(f"[Migrate] Music seed skipped: {e}")