diff --git a/docs/plans/2026-05-21-meet-kevin-revival-plan.md b/docs/plans/2026-05-21-meet-kevin-revival-plan.md new file mode 100644 index 0000000..736a49e --- /dev/null +++ b/docs/plans/2026-05-21-meet-kevin-revival-plan.md @@ -0,0 +1,827 @@ +# Meet Kevin Revival Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the v1 Meet Kevin pipeline (RSS poll → caption extraction → Claude Sonnet 4.6 structured analysis → Postgres → ticker-centric UI) inside the existing `viktor/trading` repo, and revive the K8s stack with the 3 expensive services disabled and the new watcher container added. + +**Architecture:** Single long-running async service (`services/meet_kevin_watcher/`) walks each video through 3 sequential stages and writes directly to Postgres. No Redis streams in v1. The existing `api_gateway` gains 11 new routes; the dashboard gains 5 new pages under `/meet-kevin/*`. The K8s revival is a single `infra/stacks/trading-bot/main.tf` edit (uncomment block + remove 3 disabled containers + add 1 new container + extend ExternalSecret + bump common env). + +**Tech Stack:** Python 3.12, FastAPI, SQLAlchemy 2.0 (async), Pydantic v2, Alembic, `feedparser`, `yt-dlp>=2026.04` (PyPI not apt), `anthropic>=0.40` SDK with tool-use forcing for structured output + prompt caching, React 19 + TanStack Query + recharts + Tailwind 4, Terraform/Terragrunt, Vault KV + ExternalSecret. + +**Reference**: spec at [`2026-05-21-meet-kevin-revival-design.md`](2026-05-21-meet-kevin-revival-design.md) (commit `ab382af`). The spec is the source of truth for schemas, prompts, endpoint shapes, and the Terraform diff — this plan sequences the implementation and pins down acceptance criteria per step. + +**Workspace:** all backend + dashboard work happens inside `/home/wizard/code/trading-bot/` (the cloned trading repo). The K8s revival in Phase 6 happens inside `/home/wizard/code/infra/`. Commits use `me@viktorbarzin.me` as the author email (personal-repo convention). + +**Pattern crib-sheet** (read once before starting): +- Service main loop pattern: `services/news_fetcher/main.py` — `asyncio.Event` shutdown, signal handlers, `setup_telemetry`, graceful close +- Config pattern: `services/news_fetcher/config.py` — extends `shared.config.BaseConfig`, `TRADING_` env prefix +- Model pattern: `shared/models/news.py` — SQLAlchemy 2.0 `Mapped[]`/`mapped_column()` style, `TimestampMixin` from `base.py` +- Schema pattern: `shared/schemas/news.py` — Pydantic v2 `BaseModel`, `Field(ge=…, le=…)` +- Migration pattern: `alembic/versions/b2c3d4e5f6a7_add_fundamentals_table.py` — `op.create_table` with explicit columns, no autogen +- API route pattern: `services/api_gateway/routes/news.py` — `APIRouter(prefix="/api/...")`, `_user=Depends(get_current_user)`, `request.app.state.db_session_factory` +- Test pattern: `tests/services/test_news_fetcher.py` — pytest-asyncio auto mode, `MagicMock`/`AsyncMock`, committed fixture files +- Dashboard page pattern: `dashboard/src/pages/NewsFeed.tsx` — TanStack Query, axios via `api/client.ts`, Tailwind on `bg-slate-800` cards +- Sidebar nav pattern: `dashboard/src/components/Layout.tsx`'s `navItems` array + +--- + +## Phase 1 — Data layer + +### Task 1: Pydantic schemas for the Meet Kevin pipeline + +**Files:** +- Create: `shared/schemas/meet_kevin.py` +- Modify: `tests/test_schemas.py` (append cases under a `# --- Meet Kevin schemas ---` section) + +**Required exports** (match these names exactly — they're consumed by later tasks): + +- Enums: `TickerAction` (buy/sell/hold/watch/avoid), `TimeHorizon` (intraday/days/weeks/months/long_term/unspecified), `MarketOutlook` (bullish/neutral/bearish/mixed), `VideoStatus` (discovered/captioned/analyzed/failed/skipped), `TranscriptSource` (captions_manual/captions_auto/none) +- LLM tool-input: `MeetKevinTickerMention` (symbol uppercased via `@field_validator`, conviction `Field(ge=0.0, le=1.0)`), `MeetKevinAnalysis` (market_outlook_direction, market_outlook_reasoning, macro_themes:list[str], key_risks:list[str], summary, tickers:list[MeetKevinTickerMention]) +- API response shapes: `TranscriptSegment`, `VideoSummary`, `VideoDetail`, `StockSummary`, `StockMention`, `StockTimeline`, `TimelineBucket`, `PipelineHealth` + +Full field-level schemas in the spec under "Data model" and "LLM output schema". + +- [ ] **Step 1: Write failing tests** in `tests/test_schemas.py` covering: ticker mention validates, symbol auto-uppercases, conviction out of `[0,1]` raises `pydantic.ValidationError`, full `MeetKevinAnalysis` validates with one ticker entry. + +- [ ] **Step 2: Run tests, verify they fail** with `ModuleNotFoundError`: + +```bash +python -m pytest tests/test_schemas.py -v -k "meet_kevin" +``` + +- [ ] **Step 3: Implement `shared/schemas/meet_kevin.py`** per the spec. Use Pydantic v2 (`field_validator`, `model_config`). + +- [ ] **Step 4: Re-run tests, verify pass.** + +- [ ] **Step 5: Commit** + +```bash +git add shared/schemas/meet_kevin.py tests/test_schemas.py +git -c user.email=me@viktorbarzin.me commit -m "feat: add Meet Kevin pydantic schemas (analysis + API shapes)" +``` + +--- + +### Task 2: SQLAlchemy models + +**Files:** +- Create: `shared/models/meet_kevin.py` +- Modify: `shared/models/__init__.py` (add 5 imports + `__all__` entries) +- Modify: `tests/test_models.py` (append) + +**Tables** (all use `BigInteger` PKs, `TimestampMixin`, `JSONB` for json columns, `SAEnum` with named types per the spec): +- `KevinChannel` (table `kevin_channels`) — youtube_channel_id unique, poll_enabled bool, poll_interval_seconds int default 10800, daily_cost_cap_usd Numeric(8,2) default 5.00, last_polled_at +- `KevinVideo` (table `kevin_videos`) — channel_id FK, youtube_video_id unique+indexed, title, description, published_at indexed, duration_seconds, thumbnail_url, status enum + index, failure_reason, retry_count default 0, processed_at +- `KevinTranscript` (table `kevin_transcripts`) — video_id unique FK, source enum, language, raw_text, segments_json JSONB, word_count +- `KevinAnalysis` (table `kevin_analyses`) — video_id FK indexed, model, prompt_version, market_outlook_direction enum, market_outlook_reasoning, macro_themes_json + key_risks_json JSONB, summary, raw_response_json JSONB, prompt_tokens/completion_tokens int, cost_usd Numeric(10,4) +- `KevinStockMention` (table `kevin_stock_mentions`) — video_id + analysis_id FKs, symbol indexed, action enum, conviction Numeric(4,3), time_horizon enum, rationale_quote, video_timestamp_seconds nullable + +Each table establishes `Mapped[]`/`mapped_column()` relationships so the ORM nav matches the spec (videos → transcript, analyses, mentions). + +- [ ] **Step 1**: Add `test_meet_kevin_models_importable` to `tests/test_models.py` asserting `KevinChannel.__tablename__ == "kevin_channels"` (etc.) and that `KevinVideo.__table__.c.status` is an Enum containing "discovered". + +- [ ] **Step 2**: Verify failure. + +- [ ] **Step 3**: Implement `shared/models/meet_kevin.py`. + +- [ ] **Step 4**: Update `shared/models/__init__.py` — import the 5 classes and add them to `__all__`. + +- [ ] **Step 5**: Re-run, verify pass. + +- [ ] **Step 6**: Commit + +```bash +git add shared/models/meet_kevin.py shared/models/__init__.py tests/test_models.py +git -c user.email=me@viktorbarzin.me commit -m "feat: add Meet Kevin SQLAlchemy models (5 tables)" +``` + +--- + +### Task 3: Alembic migration + +**Files:** +- Create: `alembic/versions/c3d4e5f6a7b8_add_meet_kevin_tables.py` + +**Requirements:** +- `revision = "c3d4e5f6a7b8"`, `down_revision = "b2c3d4e5f6a7"` (current head from `python -m alembic heads`) +- Create 5 named ENUM types: `kevin_video_status`, `kevin_transcript_source`, `kevin_market_outlook`, `kevin_ticker_action`, `kevin_time_horizon` +- `op.create_table` for all 5 tables matching the SQLAlchemy models exactly (column types, defaults, indexes) +- Composite index `ix_kevin_stock_mentions_symbol_created_at` on `(symbol, created_at DESC)` for ticker-timeline queries +- Partial index `ix_kevin_videos_status_pending` on `status` `WHERE status IN ('discovered', 'captioned')` for the pipeline scan +- Seed the one channel: `INSERT INTO kevin_channels (youtube_channel_id, title) VALUES ('UCUvvj5lwue7PspotMDjk5UA', 'Meet Kevin')` +- `downgrade()` reverses everything including the 5 enum types + +Pattern model: `alembic/versions/b2c3d4e5f6a7_add_fundamentals_table.py`. + +- [ ] **Step 1**: Confirm current head + +```bash +python -m alembic heads +# expected: b2c3d4e5f6a7 (head) +``` + +- [ ] **Step 2**: Write the migration file. + +- [ ] **Step 3**: Apply against the docker-compose Postgres + +```bash +docker compose up -d postgres +TRADING_DATABASE_URL=postgresql+asyncpg://trading:trading@localhost:5432/trading \ +python -m alembic upgrade head +# expected: "Running upgrade b2c3d4e5f6a7 -> c3d4e5f6a7b8" +``` + +- [ ] **Step 4**: Verify 5 tables exist: + +```bash +docker compose exec -T postgres psql -U trading -d trading -c "\dt kevin_*" +# expected: 5 rows +``` + +- [ ] **Step 5**: Round-trip check (`downgrade -1` shows 0 rows; `upgrade head` restores). + +- [ ] **Step 6**: Commit + +```bash +git add alembic/versions/c3d4e5f6a7b8_add_meet_kevin_tables.py +git -c user.email=me@viktorbarzin.me commit -m "feat: add Alembic migration for Meet Kevin tables" +``` + +--- + +## Phase 2 — Watcher service stages + +### Task 4: Service scaffold + config + +**Files:** +- Create: `services/meet_kevin_watcher/__init__.py` (empty) +- Create: `services/meet_kevin_watcher/config.py` + +**`MeetKevinWatcherConfig`** extends `BaseConfig`, exposing (defaults shown): +- `meet_kevin_channel_id: str = "UCUvvj5lwue7PspotMDjk5UA"` +- `meet_kevin_poll_interval_seconds: int = 10800` +- `meet_kevin_max_caption_retries: int = 5` +- `meet_kevin_max_llm_retries: int = 3` +- `meet_kevin_llm_model: str = "claude-sonnet-4-6"` +- `meet_kevin_prompt_version: str = "v1"` +- `meet_kevin_daily_cost_cap_usd: float = 5.0` +- `anthropic_api_key: str = ""` +- `meet_kevin_workdir: str = "/tmp/meet_kevin_captions"` +- `otel_service_name: str = "meet-kevin-watcher"` +- `otel_metrics_port: int = 9097` (next free port after market_data's 9096) + +- [ ] **Step 1**: Create the dir, empty `__init__.py`, and the config file. + +- [ ] **Step 2**: Verify importable: `python -c "from services.meet_kevin_watcher.config import MeetKevinWatcherConfig; print(MeetKevinWatcherConfig().otel_metrics_port)"` → `9097`. + +- [ ] **Step 3**: Commit + +```bash +git add services/meet_kevin_watcher/__init__.py services/meet_kevin_watcher/config.py +git -c user.email=me@viktorbarzin.me commit -m "feat: scaffold meet_kevin_watcher service + config" +``` + +--- + +### Task 5: RSS poller + +**Files:** +- Create: `services/meet_kevin_watcher/rss_poller.py` +- Create: `tests/services/meet_kevin_watcher/__init__.py` (empty) +- Create: `tests/services/meet_kevin_watcher/test_rss_poller.py` +- Create: `tests/fixtures/meet_kevin_rss.xml` (real fixture) + +**Exports:** +- `@dataclass(frozen=True) DiscoveredVideo(youtube_video_id, title, description, published_at, thumbnail_url)` +- `parse_feed(xml_bytes: bytes) -> list[DiscoveredVideo]` — uses stdlib `xml.etree.ElementTree`, returns `[]` on parse error/empty +- `async fetch_feed(channel_id: str, client: httpx.AsyncClient) -> bytes` — GET with 15s timeout, returns `b""` on HTTP error +- Atom namespaces: `{"a": "http://www.w3.org/2005/Atom", "y": "http://www.youtube.com/xml/schemas/2015", "m": "http://search.yahoo.com/mrss/"}` +- Feed URL template: `https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}` + +- [ ] **Step 1: Capture real fixture** + +```bash +mkdir -p tests/fixtures +curl -sL "https://www.youtube.com/feeds/videos.xml?channel_id=UCUvvj5lwue7PspotMDjk5UA" \ + -o tests/fixtures/meet_kevin_rss.xml +test -s tests/fixtures/meet_kevin_rss.xml || { echo "FAIL: empty fixture"; exit 1; } +``` + +- [ ] **Step 2: Write failing tests** asserting: + - `parse_feed(fixture.read_bytes())` returns ≥1 `DiscoveredVideo` with 11-char video_id, title, published_at, thumbnail starting with https + - `parse_feed(b"")` and `parse_feed(b"")` both return `[]` + +- [ ] **Step 3**: Verify failure (ModuleNotFoundError). + +- [ ] **Step 4**: Implement `rss_poller.py`. + +- [ ] **Step 5**: Verify pass. + +- [ ] **Step 6**: Commit + +```bash +git add services/meet_kevin_watcher/rss_poller.py \ + tests/services/meet_kevin_watcher/__init__.py \ + tests/services/meet_kevin_watcher/test_rss_poller.py \ + tests/fixtures/meet_kevin_rss.xml +git -c user.email=me@viktorbarzin.me commit -m "feat(meet-kevin): RSS poller for YouTube uploads" +``` + +--- + +### Task 6: Caption extractor + +**Files:** +- Create: `services/meet_kevin_watcher/caption_extractor.py` +- Create: `tests/services/meet_kevin_watcher/test_caption_extractor.py` +- Create: `tests/fixtures/sample.srt` (3-segment SRT mentioning NVDA) + +**Exports:** +- `@dataclass(frozen=True) CaptionResult(source, language, raw_text, segments, word_count)` +- `parse_srt(text: str) -> list[dict]` — segments with `{start: float, end: float, text: str}`. Handles SRT and yt-dlp's webvtt-converted variants; `[]` on empty/malformed. +- `async extract_captions(video_id: str, workdir: str, sub_lang_glob: str = "en.*") -> CaptionResult | None` — calls yt-dlp subprocess via the internal `_run_yt_dlp` helper, then parses any `*.srt` left in workdir. Prefers manual subs (no `.auto.` in filename) over auto-generated. Cleans up SRT files after parsing. Returns `None` if no SRT was produced. +- Internal `async _run_yt_dlp(cmd: list[str], cwd: str) -> int` — wraps `asyncio.create_subprocess_exec` for testability (mocked in tests). + +**yt-dlp invocation:** +``` +yt-dlp --write-auto-sub --write-sub --sub-lang en.* --skip-download --convert-subs srt \ + -o ".%(ext)s" "https://www.youtube.com/watch?v=" +``` + +- [ ] **Step 1**: Create `tests/fixtures/sample.srt` with 3 segments (one mentioning NVDA). + +- [ ] **Step 2**: Write tests: + - `parse_srt` extracts 3 segments with correct start/end/text + - `parse_srt("")` returns `[]` + - `extract_captions` returns a `CaptionResult` when yt-dlp writes an SRT (mock `_run_yt_dlp` to return 0, pre-place SRT in tmp_path) + - `extract_captions` returns `None` when no SRT lands + +- [ ] **Step 3**: Verify failure. + +- [ ] **Step 4**: Implement the module. + +- [ ] **Step 5**: Verify pass. + +- [ ] **Step 6**: Commit + +```bash +git add services/meet_kevin_watcher/caption_extractor.py \ + tests/services/meet_kevin_watcher/test_caption_extractor.py \ + tests/fixtures/sample.srt +git -c user.email=me@viktorbarzin.me commit -m "feat(meet-kevin): caption extractor via yt-dlp" +``` + +--- + +### Task 7: LLM analyzer (Claude Sonnet 4.6 with tool-use forcing) + +**Files:** +- Create: `services/meet_kevin_watcher/llm_analyzer.py` +- Create: `tests/services/meet_kevin_watcher/test_llm_analyzer.py` + +**Exports:** +- `@dataclass(frozen=True) LlmCallResult(analysis: MeetKevinAnalysis, raw_response: dict, prompt_tokens, completion_tokens, cost_usd: Decimal)` +- `SYSTEM_PROMPT` (module constant) — analyst-style system instructions per the spec's "LLM output schema" section; MUST mention `submit_analysis` (the tool name) and the structured contract +- `compute_cost_usd(model: str, input_tokens: int, output_tokens: int) -> Decimal` — uses `_PRICING` dict keyed by model; pinned prices in code: `"claude-sonnet-4-6": (Decimal("3"), Decimal("15"))` ($/M input, $/M output); unknown model → `Decimal("0")` + warning log +- `class LlmAnalyzer(client: AsyncAnthropic, model: str, prompt_version: str)` — single method `async analyze(title, description, published_at, transcript_text, transcript_segments) -> LlmCallResult` + +**Anthropic call shape:** +- `client.messages.create(model=..., max_tokens=4096, system=[{type: "text", text: SYSTEM_PROMPT, cache_control: {type: "ephemeral"}}], tools=[ANALYSIS_TOOL], tool_choice={type: "tool", name: "submit_analysis"}, messages=[{role: "user", content: }])` +- `ANALYSIS_TOOL` defines a JSON schema mirroring `MeetKevinAnalysis` (see spec) +- Response: pick the first `tool_use` content block, validate `block.input` via `MeetKevinAnalysis.model_validate(...)`. Raise `ValueError` if no `tool_use` block (caller marks failed). + +- [ ] **Step 1**: Write tests using `MagicMock`/`AsyncMock` for `anthropic.AsyncAnthropic`: + - Happy path: tool_use response with valid input → returns `LlmCallResult` with parsed `MeetKevinAnalysis`, correct token counts, `cost_usd > 0` + - No tool_use block in response → `ValueError("tool_use")` + - `compute_cost_usd("claude-sonnet-4-6", 1_000_000, 1_000_000) == Decimal("18.0000")` + - `compute_cost_usd("unknown-model", 1000, 1000) == Decimal("0")` + - `SYSTEM_PROMPT` contains `"submit_analysis"` and `"ticker"` + +- [ ] **Step 2**: Verify failure. + +- [ ] **Step 3**: Implement the module. + +- [ ] **Step 4**: Verify pass. + +- [ ] **Step 5**: Commit + +```bash +git add services/meet_kevin_watcher/llm_analyzer.py \ + tests/services/meet_kevin_watcher/test_llm_analyzer.py +git -c user.email=me@viktorbarzin.me commit -m "feat(meet-kevin): Claude Sonnet 4.6 LLM analyzer (tool-use forcing + prompt cache)" +``` + +--- + +### Task 8: Pipeline orchestrator + service main loop + +**Files:** +- Create: `services/meet_kevin_watcher/pipeline.py` +- Create: `services/meet_kevin_watcher/main.py` +- Create: `tests/services/meet_kevin_watcher/test_pipeline.py` + +**Exports from `pipeline.py`:** +- `@dataclass(frozen=True) PipelineDeps(extract_captions, analyze, daily_cost_used, model, prompt_version, daily_cost_cap_usd: Decimal, workdir)` — dependency injection container so the pipeline is unit-testable +- `async process_one_video(video: KevinVideo, session: AsyncSession, deps: PipelineDeps) -> str` — runs the next pipeline stage and returns the new status. Status transitions: + - `discovered` → `extract_captions()`. None → status=failed, failure_reason=`"no_captions"`. Otherwise insert `KevinTranscript`, advance to `captioned`. + - `captioned` → check `daily_cost_used() < daily_cost_cap_usd`. If over cap, leave as `captioned` (skip). Otherwise call `analyze()`, on success insert `KevinAnalysis` + N `KevinStockMention` rows, advance to `analyzed` and set `processed_at`. On exception, increment `retry_count`; mark `failed` after 3 retries. +- `async daily_cost_used(session) -> Decimal` — `SUM(cost_usd)` from `KevinAnalysis` where `created_at >= date_trunc('day', now())` + +**`main.py` orchestrator** (model: `services/news_fetcher/main.py`): +- `async run()` entry point: load config, set up logging + OTEL + counters (`meet_kevin.videos_discovered`, `meet_kevin.captions_extracted`, `meet_kevin.llm_calls`, `meet_kevin.llm_cost_usd`), create DB engine + session factory, instantiate `AsyncAnthropic` + `LlmAnalyzer`, build `PipelineDeps`, install SIGTERM/SIGINT signal handlers on `shutdown_event`. +- Main loop: poll RSS for each `poll_enabled` channel (use `INSERT … ON CONFLICT (youtube_video_id) DO NOTHING RETURNING id` to dedupe), then walk all videos with status in `("discovered", "captioned")` ordered by `published_at ASC`, call `process_one_video` for each, commit per-video. +- After each iteration, `await asyncio.wait_for(shutdown_event.wait(), timeout=poll_interval_seconds)` so shutdown is responsive. +- On exit: `await anthropic.close()`, `await engine.dispose()`. +- `if __name__ == "__main__": asyncio.run(run())` + +- [ ] **Step 1**: Write 3 pipeline tests using `MagicMock` for `KevinVideo` + `AsyncMock` for session: + - Happy path: `discovered` → `captioned` → `analyzed`, both deps awaited once + - No captions: returns `"failed"`, sets `failure_reason="no_captions"`, `analyze` NOT awaited + - Cost cap exceeded: status stays `"captioned"`, `analyze` NOT awaited + +- [ ] **Step 2**: Verify failure. + +- [ ] **Step 3**: Implement `pipeline.py` then `main.py`. + +- [ ] **Step 4**: Verify all watcher tests pass + +```bash +python -m pytest tests/services/meet_kevin_watcher/ -v +# expected: ~14 passing (2 RSS + 4 caption + 5 LLM + 3 pipeline) +``` + +- [ ] **Step 5**: Commit + +```bash +git add services/meet_kevin_watcher/pipeline.py \ + services/meet_kevin_watcher/main.py \ + tests/services/meet_kevin_watcher/test_pipeline.py +git -c user.email=me@viktorbarzin.me commit -m "feat(meet-kevin): pipeline orchestrator + service main loop" +``` + +--- + +## Phase 3 — API gateway + +### Task 9: `/api/meet-kevin/*` routes + +**Files:** +- Create: `services/api_gateway/routes/meet_kevin.py` +- Modify: `services/api_gateway/main.py` — import + `app.include_router(meet_kevin_router)` next to the other route registrations +- Create: `tests/api_gateway/routes/__init__.py` (empty, if missing) +- Create: `tests/api_gateway/routes/test_meet_kevin.py` + +**Router pattern** (model: `services/api_gateway/routes/news.py`): +- `router = APIRouter(prefix="/api/meet-kevin", tags=["meet-kevin"])` +- All endpoints behind `_user=Depends(get_current_user)` +- DB access via `request.app.state.db_session_factory` + +**Endpoints to implement** (full request/response shapes in the spec's "API surface" section): + +1. `GET /health` — counts by status from `kevin_videos`, last poll timestamp from `kevin_channels`, daily cost from `SUM(kevin_analyses.cost_usd) WHERE created_at >= date_trunc('day', now())`, cap from channel row +2. `GET /channels` — list all `KevinChannel` rows +3. `PATCH /channels/{id}` — accept body fields `poll_enabled`, `poll_interval_seconds`, `daily_cost_cap_usd` +4. `GET /videos?status=&q=&page=&per_page=` — paginated, ordered by `published_at DESC`, with `top_tickers` populated by joining latest `kevin_stock_mentions` per video (ordered by conviction DESC), and `outlook`/`one_line_summary` from the latest `kevin_analyses` row per video +5. `GET /videos/{id}` — single video + analysis (latest by created_at) + first 5 ticker mentions ordered by conviction DESC + `transcript_available: bool` +6. `GET /videos/{id}/transcript` — return segments_json + source + language; 404 if missing +7. `POST /videos/{id}/reprocess?stage=captions|analysis|auto` — reset status: failed→discovered for captions/auto; captioned|failed|analyzed→captioned for analysis; reset `retry_count` and `failure_reason` +8. `GET /stocks` — distinct symbols with `mention_count`, `last_seen_at`, `latest_action` (most recent mention), `latest_conviction`, `avg_conviction` +9. `GET /stocks/{symbol}` — all mentions for the uppercased symbol joined with their videos, ordered by `published_at DESC`; 404 if none +10. `GET /stocks/{symbol}/timeline?bucket=day|week` — `date_trunc(bucket, created_at)` aggregation: `avg_conviction`, `mention_count`, `net_action_score` (sum of conviction for buy minus sum of conviction for sell, others = 0) +11. `GET /dashboard` — latest analyzed video + its analysis + top 5 mentions, plus top conviction last 7 days (`SELECT symbol, max(conviction), count() ... GROUP BY symbol LIMIT 10`), plus 14-day outlook trend (`date_trunc('day', kevin_analyses.created_at)` × `market_outlook_direction` grouping) + +- [ ] **Step 1**: Write 3 API tests using FastAPI `TestClient`: + - `GET /api/meet-kevin/health` → 200 with `counts_by_status`, `daily_cost_usd`, `daily_cost_cap_usd` keys + - `GET /api/meet-kevin/videos` (empty DB) → `{"videos": [], "total": 0, "page": 1, "per_page": 20}` + - `GET /api/meet-kevin/stocks` (empty DB) → `{"stocks": []}` + + Use `monkeypatch.setenv("TRADING_DEV_MODE_AUTH_BYPASS", "true")` if the repo has that flag, otherwise stub `get_current_user` via dependency override. + +- [ ] **Step 2**: Verify failure (404 — router not registered yet). + +- [ ] **Step 3**: Implement the route module. + +- [ ] **Step 4**: Register the router in `main.py`. + +- [ ] **Step 5**: Verify pass. + +- [ ] **Step 6**: Commit + +```bash +git add services/api_gateway/routes/meet_kevin.py \ + services/api_gateway/main.py \ + tests/api_gateway/routes/test_meet_kevin.py \ + tests/api_gateway/routes/__init__.py 2>/dev/null +git -c user.email=me@viktorbarzin.me commit -m "feat(api): /api/meet-kevin/* routes (11 endpoints)" +``` + +--- + +## Phase 4 — Dashboard + +> The repo has no FE unit test suite. For each FE task, verification is `npm run build` (zero TS/ESLint errors) followed by manual browser checks once the backend is up (Task 17). + +### Task 10: TypeScript types + API client + +**Files:** +- Create: `dashboard/src/types/meetKevin.ts` +- Create: `dashboard/src/api/meetKevin.ts` + +**Types** (one-to-one with the spec's API response shapes): `TickerAction`, `TimeHorizon`, `MarketOutlook`, `VideoStatus`, `VideoSummary`, `TickerMention`, `VideoAnalysis`, `VideoDetail`, `TranscriptSegment`, `Transcript`, `StockSummary`, `StockMention`, `PipelineHealth`, `DashboardData`. + +**API client** (`meetKevinApi`) — thin wrappers on the shared `axios client` from `dashboard/src/api/client.ts`. Methods: `health`, `dashboard`, `listVideos(params)`, `getVideo(id)`, `getTranscript(id)`, `reprocess(id, stage)`, `listStocks`, `getStock(symbol)`, `getStockTimeline(symbol, bucket)`. Each method calls `client.get/post` with the path under `/meet-kevin/...` (the shared client already has `/api` as its base). + +- [ ] **Step 1**: Create the two files. + +- [ ] **Step 2**: Verify TS compiles + +```bash +cd dashboard && npm run build && cd .. +``` + +- [ ] **Step 3**: Commit + +```bash +git add dashboard/src/types/meetKevin.ts dashboard/src/api/meetKevin.ts +git -c user.email=me@viktorbarzin.me commit -m "feat(dashboard): Meet Kevin TypeScript types + API client" +``` + +--- + +### Task 11: Reusable Meet Kevin components + +**Files:** +- Create: `dashboard/src/components/meetKevin/ActionChip.tsx` +- Create: `dashboard/src/components/meetKevin/ConvictionBar.tsx` +- Create: `dashboard/src/components/meetKevin/YouTubeEmbed.tsx` +- Create: `dashboard/src/components/meetKevin/index.ts` (barrel export) + +**`ActionChip`** — accepts `action: TickerAction` prop. Color-coded chip per action: buy=green, sell=red, hold=slate, watch=yellow, avoid=rose. Tailwind classes only, no new CSS. + +**`ConvictionBar`** — accepts `value: number` (0-1). Renders a clipped div with a blue fill bar (`bg-blue-400` over `bg-slate-700`), width = `pct%`. + +**`YouTubeEmbed`** — `forwardRef`. Renders an `