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>
131 lines
5.3 KiB
Python
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
|