trading/services/api_gateway/routes/meet_kevin.py
Viktor Barzin 7b81980c66 fix(meet-kevin): API smoke-test bugs from Task 17 QA
Three issues caught during end-to-end manual QA against docker-compose:

1. SAEnum field columns serialized to Python enum NAMES ('DISCOVERED')
   but the DB enum had VALUES ('discovered'). Added `values_callable`
   to all 5 SAEnum() declarations in shared/models/meet_kevin.py so they
   emit values, matching the migration's enum literals.

2. /dashboard's "last 7 days" / "last 14 days" filters used
   `func.cast("7 days", type_=None)` which produced NullType DDL.
   Replaced with `text("now() - interval '7 days'")`.

3. /dashboard's outlook trend query repeated `func.date_trunc("day", col)`
   in SELECT, GROUP BY and ORDER BY — Postgres treats each as a separate
   parameterized expression. Hoisted into a single `day_trunc` variable
   so all three clauses reference the same SQL fragment.

All 11 /api/meet-kevin/* endpoints now return valid JSON against a
docker-compose Postgres seeded with one analyzed video + NVDA mention.
2026-05-21 20:15:08 +00:00

665 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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,
}