From 7b81980c662a2138835a79d703ec7295285822da Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Thu, 21 May 2026 20:15:08 +0000 Subject: [PATCH] fix(meet-kevin): API smoke-test bugs from Task 17 QA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- services/api_gateway/routes/meet_kevin.py | 18 +++++++++--------- shared/models/meet_kevin.py | 10 +++++----- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/services/api_gateway/routes/meet_kevin.py b/services/api_gateway/routes/meet_kevin.py index c37c933..d1a8913 100644 --- a/services/api_gateway/routes/meet_kevin.py +++ b/services/api_gateway/routes/meet_kevin.py @@ -612,14 +612,15 @@ async def get_dashboard( } # Top conviction last 7 days - seven_days_ago = func.now() - func.cast("7 days", type_=None) + 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 >= func.now() - func.cast("7 days", type_=None)) + .where(KevinStockMention.created_at >= text("now() - interval '7 days'")) .group_by(KevinStockMention.symbol) .order_by(desc(func.max(KevinStockMention.conviction))) .limit(10) @@ -634,18 +635,17 @@ async def get_dashboard( ] # 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( - func.date_trunc("day", KevinAnalysis.created_at).label("day"), + day_trunc.label("day"), KevinAnalysis.market_outlook_direction, func.count().label("count"), ) - .where(KevinAnalysis.created_at >= func.now() - func.cast("14 days", type_=None)) - .group_by( - func.date_trunc("day", KevinAnalysis.created_at), - KevinAnalysis.market_outlook_direction, - ) - .order_by(func.date_trunc("day", KevinAnalysis.created_at)) + .where(KevinAnalysis.created_at >= text("now() - interval '14 days'")) + .group_by(day_trunc, KevinAnalysis.market_outlook_direction) + .order_by(day_trunc) ) outlook_trend = [ { diff --git a/shared/models/meet_kevin.py b/shared/models/meet_kevin.py index 28df77e..06e0cd1 100644 --- a/shared/models/meet_kevin.py +++ b/shared/models/meet_kevin.py @@ -125,7 +125,7 @@ class KevinVideo(TimestampMixin, Base): duration_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True) thumbnail_url: Mapped[str | None] = mapped_column(Text, nullable=True) status: Mapped[KevinVideoStatus] = mapped_column( - SAEnum(KevinVideoStatus, name="kevin_video_status"), + SAEnum(KevinVideoStatus, name="kevin_video_status", values_callable=lambda x: [e.value for e in x]), nullable=False, default=KevinVideoStatus.DISCOVERED, index=True, @@ -159,7 +159,7 @@ class KevinTranscript(Base): BigInteger, ForeignKey("kevin_videos.id"), unique=True, nullable=False ) source: Mapped[KevinTranscriptSource] = mapped_column( - SAEnum(KevinTranscriptSource, name="kevin_transcript_source"), nullable=False + SAEnum(KevinTranscriptSource, name="kevin_transcript_source", values_callable=lambda x: [e.value for e in x]), nullable=False ) language: Mapped[str] = mapped_column(String(8), nullable=False) raw_text: Mapped[str] = mapped_column(Text, nullable=False) @@ -185,7 +185,7 @@ class KevinAnalysis(Base): model: Mapped[str] = mapped_column(String(100), nullable=False) prompt_version: Mapped[str] = mapped_column(String(50), nullable=False) market_outlook_direction: Mapped[KevinMarketOutlook] = mapped_column( - SAEnum(KevinMarketOutlook, name="kevin_market_outlook"), nullable=False + SAEnum(KevinMarketOutlook, name="kevin_market_outlook", values_callable=lambda x: [e.value for e in x]), nullable=False ) market_outlook_reasoning: Mapped[str] = mapped_column(Text, nullable=False) macro_themes_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True) @@ -222,11 +222,11 @@ class KevinStockMention(Base): String(16), nullable=False, index=True ) action: Mapped[KevinTickerAction] = mapped_column( - SAEnum(KevinTickerAction, name="kevin_ticker_action"), nullable=False + SAEnum(KevinTickerAction, name="kevin_ticker_action", values_callable=lambda x: [e.value for e in x]), nullable=False ) conviction: Mapped[Decimal] = mapped_column(Numeric(4, 3), nullable=False) time_horizon: Mapped[KevinTimeHorizon] = mapped_column( - SAEnum(KevinTimeHorizon, name="kevin_time_horizon"), nullable=False + SAEnum(KevinTimeHorizon, name="kevin_time_horizon", values_callable=lambda x: [e.value for e in x]), nullable=False ) rationale_quote: Mapped[str] = mapped_column(Text, nullable=False) video_timestamp_seconds: Mapped[int | None] = mapped_column(