fix(schemas): use enum types as field types + enforce symbol length

- Replace all Literal[...] type annotations with corresponding enum classes
  (TickerAction, TimeHorizon, MarketOutlook, VideoStatus, TranscriptSource)
  for MeetKevinTickerMention, MeetKevinAnalysis, and API response models
  (VideoSummary, VideoDetail, StockMention, StockSummary, TimelineBucket)
- Add min_length=1, max_length=10 validation to MeetKevinTickerMention.symbol
- Split test_conviction_edge_cases into two separate boundary tests
- Strengthen test_valid_ticker_mention with assertions for all 6 fields
- Trim no-information docstrings from TranscriptSegment, StockTimeline
- All 60 schema tests pass
This commit is contained in:
Viktor Barzin 2026-05-21 19:15:59 +00:00
parent 75534de71b
commit 8a412e6ae9
2 changed files with 24 additions and 31 deletions

View file

@ -77,17 +77,15 @@ class MeetKevinTickerMention(BaseModel):
""" """
symbol: str = Field( symbol: str = Field(
..., description="Stock ticker symbol (A-Z, 1-6 chars, auto-uppercased)" ..., min_length=1, max_length=10, description="Stock ticker symbol (A-Z, 1-6 chars, auto-uppercased)"
) )
action: Literal["buy", "sell", "hold", "watch", "avoid"] = Field( action: TickerAction = Field(
..., description="Recommendation action" ..., description="Recommendation action"
) )
conviction: float = Field( conviction: float = Field(
..., ge=0.0, le=1.0, description="Confidence in recommendation (0.0-1.0)" ..., ge=0.0, le=1.0, description="Confidence in recommendation (0.0-1.0)"
) )
time_horizon: Literal[ time_horizon: TimeHorizon = Field(..., description="Time horizon for the recommendation")
"intraday", "days", "weeks", "months", "long_term", "unspecified"
] = Field(..., description="Time horizon for the recommendation")
rationale_quote: str = Field( rationale_quote: str = Field(
..., description="Short verbatim or paraphrased quote from video" ..., description="Short verbatim or paraphrased quote from video"
) )
@ -110,7 +108,7 @@ class MeetKevinAnalysis(BaseModel):
Used as tool-input for the LLM analyzer and persisted as kevin_analyses. Used as tool-input for the LLM analyzer and persisted as kevin_analyses.
""" """
market_outlook_direction: Literal["bullish", "neutral", "bearish", "mixed"] = ( market_outlook_direction: MarketOutlook = (
Field(..., description="Overall market sentiment direction") Field(..., description="Overall market sentiment direction")
) )
market_outlook_reasoning: str = Field( market_outlook_reasoning: str = Field(
@ -136,8 +134,6 @@ class MeetKevinAnalysis(BaseModel):
class TranscriptSegment(BaseModel): class TranscriptSegment(BaseModel):
"""Single segment from a video transcript with timing."""
start_seconds: float = Field(..., description="Segment start time in seconds") start_seconds: float = Field(..., description="Segment start time in seconds")
end_seconds: float = Field(..., description="Segment end time in seconds") end_seconds: float = Field(..., description="Segment end time in seconds")
text: str = Field(..., description="Segment text content") text: str = Field(..., description="Segment text content")
@ -153,7 +149,7 @@ class VideoSummary(BaseModel):
title: str = Field(..., description="Video title") title: str = Field(..., description="Video title")
published_at: datetime = Field(..., description="Publication timestamp") published_at: datetime = Field(..., description="Publication timestamp")
thumbnail_url: str = Field(..., description="Thumbnail image URL") thumbnail_url: str = Field(..., description="Thumbnail image URL")
status: Literal["discovered", "captioned", "analyzed", "failed", "skipped"] = ( status: VideoStatus = (
Field(..., description="Processing status") Field(..., description="Processing status")
) )
failure_reason: str | None = Field( failure_reason: str | None = Field(
@ -176,13 +172,13 @@ class VideoDetail(BaseModel):
published_at: datetime = Field(..., description="Publication timestamp") published_at: datetime = Field(..., description="Publication timestamp")
duration_seconds: int | None = Field(default=None, description="Video duration") duration_seconds: int | None = Field(default=None, description="Video duration")
thumbnail_url: str = Field(..., description="Thumbnail image URL") thumbnail_url: str = Field(..., description="Thumbnail image URL")
status: Literal["discovered", "captioned", "analyzed", "failed", "skipped"] = ( status: VideoStatus = (
Field(..., description="Processing status") Field(..., description="Processing status")
) )
failure_reason: str | None = Field( failure_reason: str | None = Field(
default=None, description="Failure reason if status=failed" default=None, description="Failure reason if status=failed"
) )
transcript_source: Literal["captions_manual", "captions_auto", "none"] | None = ( transcript_source: TranscriptSource | None = (
Field(default=None, description="Source of captions") Field(default=None, description="Source of captions")
) )
transcript_segments: list[TranscriptSegment] = Field( transcript_segments: list[TranscriptSegment] = Field(
@ -204,15 +200,13 @@ class StockMention(BaseModel):
video_id: int = Field(..., description="Database ID of video") video_id: int = Field(..., description="Database ID of video")
youtube_video_id: str = Field(..., description="YouTube video ID for linking") youtube_video_id: str = Field(..., description="YouTube video ID for linking")
published_at: datetime = Field(..., description="Video publication date") published_at: datetime = Field(..., description="Video publication date")
action: Literal["buy", "sell", "hold", "watch", "avoid"] = Field( action: TickerAction = Field(
..., description="Recommendation action" ..., description="Recommendation action"
) )
conviction: float = Field( conviction: float = Field(
..., ge=0.0, le=1.0, description="Confidence in recommendation" ..., ge=0.0, le=1.0, description="Confidence in recommendation"
) )
time_horizon: Literal[ time_horizon: TimeHorizon = Field(..., description="Time horizon for recommendation")
"intraday", "days", "weeks", "months", "long_term", "unspecified"
] = Field(..., description="Time horizon for recommendation")
rationale_quote: str = Field( rationale_quote: str = Field(
..., description="Quote or summary of rationale" ..., description="Quote or summary of rationale"
) )
@ -224,14 +218,12 @@ class StockMention(BaseModel):
class StockSummary(BaseModel): class StockSummary(BaseModel):
"""Summary of a stock across all mentions."""
symbol: str = Field(..., description="Stock ticker") symbol: str = Field(..., description="Stock ticker")
mention_count: int = Field(..., description="Total mention count") mention_count: int = Field(..., description="Total mention count")
last_mentioned_at: datetime = Field( last_mentioned_at: datetime = Field(
..., description="Timestamp of last mention" ..., description="Timestamp of last mention"
) )
latest_action: Literal["buy", "sell", "hold", "watch", "avoid"] = Field( latest_action: TickerAction = Field(
..., description="Most recent recommendation" ..., description="Most recent recommendation"
) )
avg_conviction: float = Field( avg_conviction: float = Field(
@ -251,10 +243,8 @@ class StockSummary(BaseModel):
class TimelineBucket(BaseModel): class TimelineBucket(BaseModel):
"""Single time bucket in a sentiment timeline."""
bucket_date: str = Field(..., description="Date string (YYYY-MM-DD or YYYY-Www)") bucket_date: str = Field(..., description="Date string (YYYY-MM-DD or YYYY-Www)")
action: Literal["buy", "sell", "hold", "watch", "avoid"] | None = Field( action: TickerAction | None = Field(
default=None, description="Most common action in bucket" default=None, description="Most common action in bucket"
) )
avg_conviction: float = Field( avg_conviction: float = Field(
@ -268,8 +258,6 @@ class TimelineBucket(BaseModel):
class StockTimeline(BaseModel): class StockTimeline(BaseModel):
"""Timeline of mentions for a single stock ticker."""
symbol: str = Field(..., description="Stock ticker") symbol: str = Field(..., description="Stock ticker")
buckets: list[TimelineBucket] = Field( buckets: list[TimelineBucket] = Field(
default_factory=list, description="Time-bucketed data" default_factory=list, description="Time-bucketed data"

View file

@ -593,7 +593,7 @@ class TestTokenResponse:
class TestMeetKevinTickerMention: class TestMeetKevinTickerMention:
def test_valid_ticker_mention(self) -> None: def test_valid_ticker_mention(self) -> None:
from shared.schemas.meet_kevin import MeetKevinTickerMention from shared.schemas.meet_kevin import MeetKevinTickerMention, TickerAction, TimeHorizon
mention = MeetKevinTickerMention( mention = MeetKevinTickerMention(
symbol="AAPL", symbol="AAPL",
@ -604,7 +604,11 @@ class TestMeetKevinTickerMention:
video_timestamp_seconds=120, video_timestamp_seconds=120,
) )
assert mention.symbol == "AAPL" assert mention.symbol == "AAPL"
assert mention.action == TickerAction.BUY
assert mention.conviction == 0.85 assert mention.conviction == 0.85
assert mention.time_horizon == TimeHorizon.MONTHS
assert mention.rationale_quote == "Strong earnings growth expected"
assert mention.video_timestamp_seconds == 120
def test_symbol_auto_uppercases(self) -> None: def test_symbol_auto_uppercases(self) -> None:
from shared.schemas.meet_kevin import MeetKevinTickerMention from shared.schemas.meet_kevin import MeetKevinTickerMention
@ -642,28 +646,29 @@ class TestMeetKevinTickerMention:
rationale_quote="Negative conviction", rationale_quote="Negative conviction",
) )
def test_conviction_edge_cases(self) -> None: def test_conviction_boundary_zero_valid(self) -> None:
from shared.schemas.meet_kevin import MeetKevinTickerMention from shared.schemas.meet_kevin import MeetKevinTickerMention
# Test 0.0 m = MeetKevinTickerMention(
m1 = MeetKevinTickerMention(
symbol="GOOG", symbol="GOOG",
action="avoid", action="avoid",
conviction=0.0, conviction=0.0,
time_horizon="unspecified", time_horizon="unspecified",
rationale_quote="No confidence", rationale_quote="No confidence",
) )
assert m1.conviction == 0.0 assert m.conviction == 0.0
# Test 1.0 def test_conviction_boundary_one_valid(self) -> None:
m2 = MeetKevinTickerMention( from shared.schemas.meet_kevin import MeetKevinTickerMention
m = MeetKevinTickerMention(
symbol="MSFT", symbol="MSFT",
action="buy", action="buy",
conviction=1.0, conviction=1.0,
time_horizon="long_term", time_horizon="long_term",
rationale_quote="Maximum confidence", rationale_quote="Maximum confidence",
) )
assert m2.conviction == 1.0 assert m.conviction == 1.0
def test_timestamp_optional(self) -> None: def test_timestamp_optional(self) -> None:
from shared.schemas.meet_kevin import MeetKevinTickerMention from shared.schemas.meet_kevin import MeetKevinTickerMention