trading/tests/services/meet_kevin_watcher/test_pipeline.py
Viktor Barzin 8f5ee8f1c3 feat(meet-kevin): pipeline orchestrator + service main loop
Implements Task 8 of the Meet Kevin revival plan.

- pipeline.py: PipelineDeps dataclass (frozen, DI-friendly), process_one_video
  state machine (discovered→captioned→analyzed with retry/cost-cap logic),
  and daily_cost_used() SQL helper.
- main.py: async run() entry point with RSS poll loop, per-video pipeline
  processing, OTEL counters, SIGTERM/SIGINT shutdown, httpx client lifecycle,
  and clean Anthropic/DB teardown.
- tests: 5 pipeline unit tests (happy path, no captions, cost cap, retry
  increment, failed-after-3-retries) all passing; full watcher suite 56/56.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 19:48:43 +00:00

131 lines
5.3 KiB
Python

"""Tests for the Meet Kevin pipeline orchestrator (Task 8).
Tests use AsyncMock/MagicMock to avoid any real DB or LLM calls.
"""
import pytest
from decimal import Decimal
from unittest.mock import AsyncMock, MagicMock
from services.meet_kevin_watcher.pipeline import process_one_video, PipelineDeps
from services.meet_kevin_watcher.caption_extractor import CaptionResult
from services.meet_kevin_watcher.llm_analyzer import LlmCallResult
from shared.schemas.meet_kevin import (
MeetKevinAnalysis, MeetKevinTickerMention,
TickerAction, TimeHorizon, MarketOutlook,
)
def _make_analysis():
return MeetKevinAnalysis(
market_outlook_direction=MarketOutlook.BEARISH,
market_outlook_reasoning="x",
macro_themes=["x"],
key_risks=["x"],
summary="x",
tickers=[
MeetKevinTickerMention(
symbol="NVDA", action=TickerAction.SELL, conviction=0.8,
time_horizon=TimeHorizon.WEEKS, rationale_quote="x",
video_timestamp_seconds=10,
)
],
)
async def test_process_one_video_happy_path():
"""discovered -> captioned -> analyzed; both deps awaited once."""
video = MagicMock(id=1, youtube_video_id="vid", status="discovered", retry_count=0)
session = AsyncMock()
# Wire session.execute to return something for the transcript fetch
transcript = MagicMock(raw_text="hello NVDA", segments_json=[{"start": 0.0, "end": 1.0, "text": "hello"}])
session.execute = AsyncMock(return_value=MagicMock(scalar_one=lambda: transcript))
deps = PipelineDeps(
extract_captions=AsyncMock(return_value=CaptionResult(
source="captions_auto", language="en", raw_text="hello NVDA",
segments=[{"start": 0.0, "end": 1.0, "text": "hello"}], word_count=2,
)),
analyze=AsyncMock(return_value=LlmCallResult(
analysis=_make_analysis(),
raw_response={"stop_reason": "tool_use"},
prompt_tokens=100, completion_tokens=50, cost_usd=Decimal("0.05"),
)),
daily_cost_used=AsyncMock(return_value=Decimal("0")),
model="claude-sonnet-4-6", prompt_version="v1",
daily_cost_cap_usd=Decimal("5"), workdir="/tmp",
)
new_status = await process_one_video(video, session, deps)
assert new_status == "analyzed"
deps.extract_captions.assert_awaited_once()
deps.analyze.assert_awaited_once()
async def test_process_one_video_no_captions_marks_failed():
video = MagicMock(id=1, youtube_video_id="vid", status="discovered", retry_count=0)
session = AsyncMock()
deps = PipelineDeps(
extract_captions=AsyncMock(return_value=None),
analyze=AsyncMock(),
daily_cost_used=AsyncMock(return_value=Decimal("0")),
model="x", prompt_version="v1",
daily_cost_cap_usd=Decimal("5"), workdir="/tmp",
)
new_status = await process_one_video(video, session, deps)
assert new_status == "failed"
assert video.failure_reason == "no_captions"
deps.analyze.assert_not_awaited()
async def test_process_one_video_cost_cap_skips_llm():
"""When daily cost cap reached, video stays captioned and analyze NOT called."""
video = MagicMock(id=1, youtube_video_id="vid", status="captioned", retry_count=0)
session = AsyncMock()
deps = PipelineDeps(
extract_captions=AsyncMock(),
analyze=AsyncMock(),
daily_cost_used=AsyncMock(return_value=Decimal("5.01")),
model="x", prompt_version="v1",
daily_cost_cap_usd=Decimal("5"), workdir="/tmp",
)
new_status = await process_one_video(video, session, deps)
assert new_status == "captioned"
deps.analyze.assert_not_awaited()
async def test_process_one_video_llm_error_increments_retry():
"""LLM exception increments retry_count; video stays captioned below threshold."""
video = MagicMock(id=1, youtube_video_id="vid", status="captioned", retry_count=0)
session = AsyncMock()
transcript = MagicMock(raw_text="hello", segments_json=[])
session.execute = AsyncMock(return_value=MagicMock(scalar_one=lambda: transcript))
deps = PipelineDeps(
extract_captions=AsyncMock(),
analyze=AsyncMock(side_effect=ValueError("LLM exploded")),
daily_cost_used=AsyncMock(return_value=Decimal("0")),
model="x", prompt_version="v1",
daily_cost_cap_usd=Decimal("5"), workdir="/tmp",
)
new_status = await process_one_video(video, session, deps)
assert new_status == "captioned"
assert video.retry_count == 1
async def test_process_one_video_llm_error_marks_failed_after_3_retries():
"""After 3 retries, the video is marked failed."""
video = MagicMock(id=1, youtube_video_id="vid", status="captioned", retry_count=2)
session = AsyncMock()
transcript = MagicMock(raw_text="hello", segments_json=[])
session.execute = AsyncMock(return_value=MagicMock(scalar_one=lambda: transcript))
deps = PipelineDeps(
extract_captions=AsyncMock(),
analyze=AsyncMock(side_effect=RuntimeError("API down")),
daily_cost_used=AsyncMock(return_value=Decimal("0")),
model="x", prompt_version="v1",
daily_cost_cap_usd=Decimal("5"), workdir="/tmp",
)
new_status = await process_one_video(video, session, deps)
assert new_status == "failed"
assert "RuntimeError" in video.failure_reason