"""Meet Kevin pipeline endpoints — /api/meet-kevin/*.""" from __future__ import annotations from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query, Request from sqlalchemy import case, desc, func, select from services.api_gateway.auth.middleware import get_current_user from shared.models.meet_kevin import ( KevinAnalysis, KevinChannel, KevinStockMention, KevinTickerAction, KevinTranscript, KevinVideo, KevinVideoStatus, ) router = APIRouter(prefix="/api/meet-kevin", tags=["meet-kevin"]) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _video_summary_dict(video: KevinVideo, top_tickers: list, analysis: KevinAnalysis | None) -> dict: return { "id": video.id, "youtube_video_id": video.youtube_video_id, "title": video.title, "published_at": video.published_at.isoformat() if video.published_at else None, "thumbnail_url": video.thumbnail_url, "status": video.status.value if video.status else None, "failure_reason": video.failure_reason, "retry_count": video.retry_count, "top_tickers": top_tickers, "outlook": analysis.market_outlook_direction.value if analysis else None, "one_line_summary": (analysis.summary[:200] if analysis and analysis.summary else None), } def _analysis_dict(analysis: KevinAnalysis) -> dict: return { "id": analysis.id, "model": analysis.model, "prompt_version": analysis.prompt_version, "market_outlook_direction": analysis.market_outlook_direction.value, "market_outlook_reasoning": analysis.market_outlook_reasoning, "macro_themes": analysis.macro_themes_json or [], "key_risks": analysis.key_risks_json or [], "summary": analysis.summary, "prompt_tokens": analysis.prompt_tokens, "completion_tokens": analysis.completion_tokens, "cost_usd": float(analysis.cost_usd), "created_at": analysis.created_at.isoformat(), } def _mention_dict(mention: KevinStockMention) -> dict: return { "id": mention.id, "symbol": mention.symbol, "action": mention.action.value, "conviction": float(mention.conviction), "time_horizon": mention.time_horizon.value, "rationale_quote": mention.rationale_quote, "video_timestamp_seconds": mention.video_timestamp_seconds, "created_at": mention.created_at.isoformat(), } # --------------------------------------------------------------------------- # Health # --------------------------------------------------------------------------- @router.get("/health") async def health( request: Request, _user: dict = Depends(get_current_user), ) -> dict: """Pipeline health: status counts, daily cost, and last poll timestamp.""" db = request.app.state.db_session_factory async with db() as session: counts_by_status: dict[str, int] = {} for status in KevinVideoStatus: result = await session.execute( select(func.count()).select_from(KevinVideo).where(KevinVideo.status == status) ) counts_by_status[status.value] = result.scalar() or 0 # Daily cost today_start = func.date_trunc("day", func.now()) cost_result = await session.execute( select(func.sum(KevinAnalysis.cost_usd)).where( KevinAnalysis.created_at >= today_start ) ) daily_cost = float(cost_result.scalar() or 0.0) # Last polled_at + cap from first channel poll_result = await session.execute( select(KevinChannel.last_polled_at).order_by(desc(KevinChannel.id)).limit(1) ) last_polled_at = poll_result.scalar_one_or_none() cap_result = await session.execute( select(KevinChannel.daily_cost_cap_usd).order_by(desc(KevinChannel.id)).limit(1) ) cap = cap_result.scalar_one_or_none() daily_cost_cap = float(cap) if cap is not None else 5.0 return { "counts_by_status": counts_by_status, "daily_cost_usd": daily_cost, "daily_cost_cap_usd": daily_cost_cap, "cost_capped": daily_cost >= daily_cost_cap, "last_polled_at": last_polled_at.isoformat() if last_polled_at else None, } # --------------------------------------------------------------------------- # Channels # --------------------------------------------------------------------------- @router.get("/channels") async def list_channels( request: Request, _user: dict = Depends(get_current_user), ) -> dict: """List all KevinChannel rows.""" db = request.app.state.db_session_factory async with db() as session: result = await session.execute(select(KevinChannel).order_by(KevinChannel.id)) channels = result.scalars().all() return { "channels": [ { "id": ch.id, "youtube_channel_id": ch.youtube_channel_id, "title": ch.title, "poll_enabled": ch.poll_enabled, "poll_interval_seconds": ch.poll_interval_seconds, "daily_cost_cap_usd": float(ch.daily_cost_cap_usd), "last_polled_at": ch.last_polled_at.isoformat() if ch.last_polled_at else None, "created_at": ch.created_at.isoformat(), "updated_at": ch.updated_at.isoformat(), } for ch in channels ] } @router.patch("/channels/{channel_id}") async def patch_channel( channel_id: int, request: Request, _user: dict = Depends(get_current_user), ) -> dict: """Update poll_enabled, poll_interval_seconds, or daily_cost_cap_usd on a channel.""" body = await request.json() allowed = {"poll_enabled", "poll_interval_seconds", "daily_cost_cap_usd"} updates = {k: v for k, v in body.items() if k in allowed} db = request.app.state.db_session_factory async with db() as session: result = await session.execute( select(KevinChannel).where(KevinChannel.id == channel_id) ) channel = result.scalar_one_or_none() if channel is None: raise HTTPException(status_code=404, detail="Channel not found") for field, value in updates.items(): setattr(channel, field, value) channel.updated_at = datetime.now(timezone.utc) await session.commit() await session.refresh(channel) return { "id": channel.id, "youtube_channel_id": channel.youtube_channel_id, "title": channel.title, "poll_enabled": channel.poll_enabled, "poll_interval_seconds": channel.poll_interval_seconds, "daily_cost_cap_usd": float(channel.daily_cost_cap_usd), "last_polled_at": channel.last_polled_at.isoformat() if channel.last_polled_at else None, } # --------------------------------------------------------------------------- # Videos # --------------------------------------------------------------------------- @router.get("/videos") async def list_videos( request: Request, _user: dict = Depends(get_current_user), status: str | None = Query(default=None), q: str | None = Query(default=None), page: int = Query(default=1, ge=1), per_page: int = Query(default=20, ge=1, le=100), ) -> dict: """Paginated video feed ordered by published_at DESC. Each item includes top_tickers (top 5 unique by conviction DESC) and outlook/one_line_summary from the latest analysis row. """ db = request.app.state.db_session_factory async with db() as session: base = select(KevinVideo).order_by(desc(KevinVideo.published_at)) count_base = select(func.count()).select_from(KevinVideo) if status: try: status_enum = KevinVideoStatus(status) except ValueError: raise HTTPException(status_code=400, detail=f"Invalid status: {status}") base = base.where(KevinVideo.status == status_enum) count_base = count_base.where(KevinVideo.status == status_enum) if q: pattern = f"%{q}%" base = base.where(KevinVideo.title.ilike(pattern)) count_base = count_base.where(KevinVideo.title.ilike(pattern)) total = (await session.execute(count_base)).scalar() or 0 offset = (page - 1) * per_page result = await session.execute(base.offset(offset).limit(per_page)) videos = result.scalars().all() # For each video, fetch the latest analysis and top tickers video_rows = [] for video in videos: analysis = None top_tickers: list[dict] = [] if video.status == KevinVideoStatus.ANALYZED: analysis_result = await session.execute( select(KevinAnalysis) .where(KevinAnalysis.video_id == video.id) .order_by(desc(KevinAnalysis.created_at)) .limit(1) ) analysis = analysis_result.scalar_one_or_none() if analysis: mentions_result = await session.execute( select(KevinStockMention) .where(KevinStockMention.analysis_id == analysis.id) .order_by(desc(KevinStockMention.conviction)) .limit(5) ) top_tickers = [ {"symbol": m.symbol, "action": m.action.value, "conviction": float(m.conviction)} for m in mentions_result.scalars().all() ] video_rows.append(_video_summary_dict(video, top_tickers, analysis)) return { "videos": video_rows, "total": total, "page": page, "per_page": per_page, "pages": (total + per_page - 1) // per_page if per_page else 0, } @router.get("/videos/{video_id}") async def get_video( video_id: int, request: Request, _user: dict = Depends(get_current_user), ) -> dict: """Single video + latest analysis + first 5 ticker mentions by conviction DESC.""" db = request.app.state.db_session_factory async with db() as session: result = await session.execute( select(KevinVideo).where(KevinVideo.id == video_id) ) video = result.scalar_one_or_none() if video is None: raise HTTPException(status_code=404, detail="Video not found") analysis = None mentions: list[dict] = [] transcript_available = False # Check transcript trans_result = await session.execute( select(KevinTranscript.id).where(KevinTranscript.video_id == video_id) ) transcript_available = trans_result.scalar_one_or_none() is not None # Latest analysis analysis_result = await session.execute( select(KevinAnalysis) .where(KevinAnalysis.video_id == video_id) .order_by(desc(KevinAnalysis.created_at)) .limit(1) ) analysis = analysis_result.scalar_one_or_none() if analysis: mentions_result = await session.execute( select(KevinStockMention) .where(KevinStockMention.analysis_id == analysis.id) .order_by(desc(KevinStockMention.conviction)) .limit(5) ) mentions = [_mention_dict(m) for m in mentions_result.scalars().all()] return { "id": video.id, "youtube_video_id": video.youtube_video_id, "title": video.title, "description": video.description, "published_at": video.published_at.isoformat() if video.published_at else None, "duration_seconds": video.duration_seconds, "thumbnail_url": video.thumbnail_url, "status": video.status.value if video.status else None, "failure_reason": video.failure_reason, "retry_count": video.retry_count, "transcript_available": transcript_available, "analysis": _analysis_dict(analysis) if analysis else None, "top_mentions": mentions, } @router.get("/videos/{video_id}/transcript") async def get_video_transcript( video_id: int, request: Request, _user: dict = Depends(get_current_user), ) -> dict: """Return transcript segments_json + source + language; 404 if missing.""" db = request.app.state.db_session_factory async with db() as session: result = await session.execute( select(KevinTranscript).where(KevinTranscript.video_id == video_id) ) transcript = result.scalar_one_or_none() if transcript is None: raise HTTPException(status_code=404, detail="Transcript not found") return { "video_id": video_id, "source": transcript.source.value, "language": transcript.language, "segments_json": transcript.segments_json, "word_count": transcript.word_count, } @router.post("/videos/{video_id}/reprocess") async def reprocess_video( video_id: int, request: Request, _user: dict = Depends(get_current_user), stage: str = Query(default="auto"), ) -> dict: """Reset video status to trigger reprocessing. stage=captions|auto → reset failed→discovered (retry caption extraction) stage=analysis → reset captioned|failed|analyzed→captioned (retry analysis) """ db = request.app.state.db_session_factory async with db() as session: result = await session.execute( select(KevinVideo).where(KevinVideo.id == video_id) ) video = result.scalar_one_or_none() if video is None: raise HTTPException(status_code=404, detail="Video not found") current = video.status if stage in ("captions", "auto"): if current != KevinVideoStatus.FAILED: raise HTTPException( status_code=400, detail=f"Cannot reprocess captions: video status is '{current.value}' (expected 'failed')", ) video.status = KevinVideoStatus.DISCOVERED video.retry_count = 0 video.failure_reason = None elif stage == "analysis": allowed = {KevinVideoStatus.CAPTIONED, KevinVideoStatus.FAILED, KevinVideoStatus.ANALYZED} if current not in allowed: raise HTTPException( status_code=400, detail=f"Cannot reprocess analysis: video status is '{current.value}'", ) video.status = KevinVideoStatus.CAPTIONED video.retry_count = 0 video.failure_reason = None else: raise HTTPException(status_code=400, detail=f"Unknown stage: {stage}") await session.commit() return { "video_id": video_id, "stage": stage, "new_status": video.status.value, } # --------------------------------------------------------------------------- # Stocks # --------------------------------------------------------------------------- @router.get("/stocks") async def list_stocks( request: Request, _user: dict = Depends(get_current_user), ) -> dict: """Distinct symbols with mention_count, last_seen_at, latest_action, latest_conviction, avg_conviction.""" db = request.app.state.db_session_factory async with db() as session: # Aggregate per symbol agg = ( select( KevinStockMention.symbol, func.count().label("mention_count"), func.max(KevinStockMention.created_at).label("last_seen_at"), func.avg(KevinStockMention.conviction).label("avg_conviction"), ) .group_by(KevinStockMention.symbol) .order_by(desc(func.max(KevinStockMention.created_at))) ) result = await session.execute(agg) rows = result.all() stocks = [] for row in rows: # Latest mention for action + conviction latest_result = await session.execute( select(KevinStockMention) .where(KevinStockMention.symbol == row.symbol) .order_by(desc(KevinStockMention.created_at)) .limit(1) ) latest = latest_result.scalar_one_or_none() stocks.append({ "symbol": row.symbol, "mention_count": row.mention_count, "last_seen_at": row.last_seen_at.isoformat() if row.last_seen_at else None, "latest_action": latest.action.value if latest else None, "latest_conviction": float(latest.conviction) if latest else None, "avg_conviction": float(row.avg_conviction) if row.avg_conviction else 0.0, }) return {"stocks": stocks} @router.get("/stocks/{symbol}") async def get_stock( symbol: str, request: Request, _user: dict = Depends(get_current_user), ) -> dict: """All mentions for a symbol joined with their videos, ordered by published_at DESC; 404 if none.""" symbol = symbol.upper() db = request.app.state.db_session_factory async with db() as session: result = await session.execute( select(KevinStockMention, KevinVideo) .join(KevinVideo, KevinStockMention.video_id == KevinVideo.id) .where(KevinStockMention.symbol == symbol) .order_by(desc(KevinVideo.published_at)) ) rows = result.all() if not rows: raise HTTPException(status_code=404, detail=f"No mentions found for {symbol}") return { "symbol": symbol, "mentions": [ { "mention_id": mention.id, "video_id": video.id, "youtube_video_id": video.youtube_video_id, "video_title": video.title, "published_at": video.published_at.isoformat() if video.published_at else None, "action": mention.action.value, "conviction": float(mention.conviction), "time_horizon": mention.time_horizon.value, "rationale_quote": mention.rationale_quote, "video_timestamp_seconds": mention.video_timestamp_seconds, "created_at": mention.created_at.isoformat(), } for mention, video in rows ], } @router.get("/stocks/{symbol}/timeline") async def get_stock_timeline( symbol: str, request: Request, _user: dict = Depends(get_current_user), bucket: str = Query(default="day"), ) -> dict: """date_trunc aggregation: avg_conviction, mention_count, net_action_score by day or week.""" if bucket not in ("day", "week"): raise HTTPException(status_code=400, detail="bucket must be 'day' or 'week'") symbol = symbol.upper() db = request.app.state.db_session_factory async with db() as session: # net_action_score: +conviction for buy, -conviction for sell, 0 otherwise net_score_expr = func.sum( case( (KevinStockMention.action == KevinTickerAction.BUY, KevinStockMention.conviction), (KevinStockMention.action == KevinTickerAction.SELL, -KevinStockMention.conviction), else_=0, ) ).label("net_action_score") bucket_col = func.date_trunc(bucket, KevinStockMention.created_at).label("bucket_date") timeline_query = ( select( bucket_col, func.avg(KevinStockMention.conviction).label("avg_conviction"), func.count().label("mention_count"), net_score_expr, ) .where(KevinStockMention.symbol == symbol) .group_by(bucket_col) .order_by(bucket_col) ) result = await session.execute(timeline_query) rows = result.all() return { "symbol": symbol, "bucket": bucket, "timeline": [ { "bucket_date": row.bucket_date.isoformat() if row.bucket_date else None, "avg_conviction": float(row.avg_conviction) if row.avg_conviction else 0.0, "mention_count": row.mention_count, "net_action_score": float(row.net_action_score) if row.net_action_score else 0.0, } for row in rows ], } # --------------------------------------------------------------------------- # Dashboard # --------------------------------------------------------------------------- @router.get("/dashboard") async def get_dashboard( request: Request, _user: dict = Depends(get_current_user), ) -> dict: """Aggregate home view: latest video + analysis, top conviction, 14d outlook trend.""" db = request.app.state.db_session_factory async with db() as session: # Latest analyzed video video_result = await session.execute( select(KevinVideo) .where(KevinVideo.status == KevinVideoStatus.ANALYZED) .order_by(desc(KevinVideo.published_at)) .limit(1) ) latest_video = video_result.scalar_one_or_none() latest_video_dict = None latest_analysis_dict = None top_mentions: list[dict] = [] if latest_video: analysis_result = await session.execute( select(KevinAnalysis) .where(KevinAnalysis.video_id == latest_video.id) .order_by(desc(KevinAnalysis.created_at)) .limit(1) ) latest_analysis = analysis_result.scalar_one_or_none() if latest_analysis: latest_analysis_dict = _analysis_dict(latest_analysis) mentions_result = await session.execute( select(KevinStockMention) .where(KevinStockMention.analysis_id == latest_analysis.id) .order_by(desc(KevinStockMention.conviction)) .limit(5) ) top_mentions = [_mention_dict(m) for m in mentions_result.scalars().all()] latest_video_dict = { "id": latest_video.id, "youtube_video_id": latest_video.youtube_video_id, "title": latest_video.title, "published_at": latest_video.published_at.isoformat() if latest_video.published_at else None, "thumbnail_url": latest_video.thumbnail_url, } # Top conviction last 7 days from sqlalchemy import text seven_days_ago = text("now() - interval '7 days'") top_conviction_result = await session.execute( select( KevinStockMention.symbol, func.max(KevinStockMention.conviction).label("max_conviction"), func.count().label("mention_count"), ) .where(KevinStockMention.created_at >= text("now() - interval '7 days'")) .group_by(KevinStockMention.symbol) .order_by(desc(func.max(KevinStockMention.conviction))) .limit(10) ) top_conviction = [ { "symbol": row.symbol, "max_conviction": float(row.max_conviction), "mention_count": row.mention_count, } for row in top_conviction_result.all() ] # 14-day outlook trend: date × direction → count # Use a single trunc expression so SELECT/GROUP BY/ORDER BY reference the same SQL fragment day_trunc = func.date_trunc("day", KevinAnalysis.created_at) outlook_result = await session.execute( select( day_trunc.label("day"), KevinAnalysis.market_outlook_direction, func.count().label("count"), ) .where(KevinAnalysis.created_at >= text("now() - interval '14 days'")) .group_by(day_trunc, KevinAnalysis.market_outlook_direction) .order_by(day_trunc) ) outlook_trend = [ { "day": row.day.isoformat() if row.day else None, "direction": row.market_outlook_direction.value, "count": row.count, } for row in outlook_result.all() ] return { "latest_video": latest_video_dict, "latest_analysis": latest_analysis_dict, "top_mentions": top_mentions, "top_conviction_7d": top_conviction, "outlook_trend_14d": outlook_trend, }