diff --git a/.gsd/milestones/M024/M024-ROADMAP.md b/.gsd/milestones/M024/M024-ROADMAP.md
index 9a7efba..e60c8ee 100644
--- a/.gsd/milestones/M024/M024-ROADMAP.md
+++ b/.gsd/milestones/M024/M024-ROADMAP.md
@@ -9,6 +9,6 @@ Shorts pipeline goes end-to-end with captioning and templates. Player gets key m
| S01 | [A] Shorts Publishing Flow | medium | — | ✅ | Creator approves a short → it renders → gets a shareable URL and embed code |
| S02 | [A] Key Moment Pins on Player Timeline | low | — | ✅ | Key technique moments appear as clickable pins on the player timeline |
| S03 | [A] Embed Support (iframe Snippet) | low | — | ✅ | Creators can copy an iframe embed snippet to put the player on their own site |
-| S04 | [B] Auto-Captioning + Template System | medium | — | ⬜ | Shorts have Whisper-generated animated subtitles and creator-configurable intro/outro cards |
+| S04 | [B] Auto-Captioning + Template System | medium | — | ✅ | Shorts have Whisper-generated animated subtitles and creator-configurable intro/outro cards |
| S05 | [B] Citation UX Improvements | low | — | ⬜ | Chat citations show timestamp links that seek the player and source cards with video thumbnails |
| S06 | Forgejo KB Update — Shorts, Embed, Citations | low | S01, S02, S03, S04, S05 | ⬜ | Forgejo wiki updated with shorts pipeline, embed system, citation architecture |
diff --git a/.gsd/milestones/M024/slices/S04/S04-SUMMARY.md b/.gsd/milestones/M024/slices/S04/S04-SUMMARY.md
new file mode 100644
index 0000000..1c0c075
--- /dev/null
+++ b/.gsd/milestones/M024/slices/S04/S04-SUMMARY.md
@@ -0,0 +1,119 @@
+---
+id: S04
+parent: M024
+milestone: M024
+provides:
+ - caption_generator.py module for ASS subtitle generation from word-level timings
+ - card_renderer.py module for ffmpeg-based intro/outro card generation and concat
+ - Admin API for creator shorts template CRUD
+ - captions_enabled column on GeneratedShort model
+ - shorts_template JSONB column on Creator model
+requires:
+ []
+affects:
+ - S06
+key_files:
+ - backend/pipeline/caption_generator.py
+ - backend/pipeline/card_renderer.py
+ - backend/pipeline/shorts_generator.py
+ - backend/pipeline/stages.py
+ - backend/models.py
+ - backend/routers/creators.py
+ - backend/schemas.py
+ - frontend/src/api/templates.ts
+ - frontend/src/pages/HighlightQueue.tsx
+ - frontend/src/pages/HighlightQueue.module.css
+ - alembic/versions/027_add_captions_enabled.py
+ - alembic/versions/028_add_shorts_template.py
+ - backend/pipeline/test_caption_generator.py
+ - backend/pipeline/test_card_renderer.py
+key_decisions:
+ - ASS karaoke format with per-word Dialogue lines and \k tags for word-by-word highlighting
+ - Caption and card rendering failures are non-blocking — shorts proceed without them
+ - Cards include silent audio track (anullsrc) for codec-compatible concat with audio main clips
+ - Admin template endpoints on separate admin_router to keep public /creators endpoints untouched
+ - Template JSONB keys match card_renderer.parse_template_config expectations for zero-translation at pipeline read time
+patterns_established:
+ - Non-blocking enrichment stages: caption/card failures log WARNING and skip, never fail the parent short generation
+ - ffmpeg concat demuxer pattern: build_concat_list writes file manifest, concat_segments runs demuxer with -c copy
+ - JSONB template config with parse_template_config normalizer that fills defaults for missing/null fields
+observability_surfaces:
+ - GeneratedShort.captions_enabled boolean indicates whether captions were successfully generated
+ - WARNING-level logs when caption generation or card rendering fails (non-blocking)
+drill_down_paths:
+ - .gsd/milestones/M024/slices/S04/tasks/T01-SUMMARY.md
+ - .gsd/milestones/M024/slices/S04/tasks/T02-SUMMARY.md
+ - .gsd/milestones/M024/slices/S04/tasks/T03-SUMMARY.md
+duration: ""
+verification_result: passed
+completed_at: 2026-04-04T11:27:20.687Z
+blocker_discovered: false
+---
+
+# S04: [B] Auto-Captioning + Template System
+
+**Shorts now have Whisper-generated ASS karaoke subtitles and creator-configurable intro/outro cards, with admin UI for template management and per-highlight captions toggle.**
+
+## What Happened
+
+Three tasks delivered the full auto-captioning and template system for shorts.
+
+**T01 — Caption Generator:** Built `caption_generator.py` with `generate_ass_captions()` that converts word-level timings from Whisper transcripts into ASS (Advanced SubStation Alpha) subtitle files with `\k` karaoke tags for word-by-word highlighting. Each word gets its own Dialogue line with clip-relative timing (offsets by clip_start). Modified `extract_clip()` in `shorts_generator.py` to accept an optional `ass_path` and chain the `ass=` filter into ffmpeg's `-vf` string. Wired into `stage_generate_shorts` with non-blocking error handling — caption generation failures log WARNING but don't fail the stage. Added `captions_enabled` boolean to `GeneratedShort` model (migration 027). 17 unit tests cover time formatting, ASS structure, clip offset math, karaoke duration, empty/whitespace handling, custom styles, and negative time clamping.
+
+**T02 — Card Renderer:** Created `card_renderer.py` with `render_card()` (lavfi command builder using `color` + `drawtext`), `render_card_to_file()` (ffmpeg executor), `build_concat_list()` and `concat_segments()` (ffmpeg concat demuxer pipeline), and `parse_template_config()` (JSONB normalizer with defaults). Cards include a silent audio track via `anullsrc` so concat with audio-bearing main clips works without stream mismatch. Added `shorts_template` JSONB column to Creator model (migration 028). Added `extract_clip_with_template()` to `shorts_generator.py` for intro/main/outro concatenation. Card render failures are non-blocking per preset. 28 unit tests.
+
+**T03 — Admin UI and Endpoints:** Added `ShortsTemplateConfig` and `ShortsTemplateUpdate` Pydantic schemas with validation (hex color pattern, duration range 1.0-5.0). Created `admin_router` in `creators.py` with GET/PUT endpoints at `/admin/creators/{id}/shorts-template`. Built collapsible template config panel in HighlightQueue with intro/outro text inputs, duration sliders, show/hide toggles, color picker, and font selection. Added per-highlight captions toggle checkbox. Updated generate-shorts endpoint and Celery task to accept and respect the `captions` parameter. Frontend builds clean.
+
+## Verification
+
+All three verification gates pass:
+1. `cd backend && python -m pytest pipeline/test_caption_generator.py -v` — 17/17 passed (0.02s)
+2. `cd backend && python -m pytest pipeline/test_card_renderer.py -v` — 28/28 passed (0.27s)
+3. `cd frontend && npm run build` — clean build, exit 0
+4. All module imports verified: caption_generator, card_renderer, creators router
+
+## Requirements Advanced
+
+None.
+
+## Requirements Validated
+
+None.
+
+## New Requirements Surfaced
+
+None.
+
+## Requirements Invalidated or Re-scoped
+
+None.
+
+## Deviations
+
+None.
+
+## Known Limitations
+
+Cards use a fixed black background with drawtext — no image/logo support in intro/outro cards yet. Font availability depends on the host system (Inter may not be present in Docker containers). Caption ASS files are shared across all format presets (9:16, 1:1, 16:9) — font sizing may not be optimal for all aspect ratios.
+
+## Follow-ups
+
+Font bundling in Docker image for consistent card rendering. Per-preset ASS style tuning for different aspect ratios. Logo/image support in intro/outro cards.
+
+## Files Created/Modified
+
+- `backend/pipeline/caption_generator.py` — New: ASS subtitle generator with karaoke word-by-word highlighting
+- `backend/pipeline/card_renderer.py` — New: ffmpeg-based card renderer with concat demuxer pipeline
+- `backend/pipeline/shorts_generator.py` — Modified: added ass_path param to extract_clip, added extract_clip_with_template for intro/outro concat
+- `backend/pipeline/stages.py` — Modified: stage_generate_shorts now loads transcripts for captions and creator templates for cards
+- `backend/models.py` — Modified: added captions_enabled to GeneratedShort, shorts_template JSONB to Creator
+- `backend/routers/creators.py` — Modified: added admin_router with GET/PUT shorts-template endpoints
+- `backend/schemas.py` — Modified: added ShortsTemplateConfig and ShortsTemplateUpdate schemas
+- `backend/main.py` — Modified: mounted creators admin_router
+- `backend/routers/shorts.py` — Modified: generate-shorts endpoint accepts captions param
+- `frontend/src/api/templates.ts` — New: API client for shorts template CRUD
+- `frontend/src/api/shorts.ts` — Modified: generate shorts call passes captions flag
+- `frontend/src/pages/HighlightQueue.tsx` — Modified: added collapsible template config panel and captions toggle
+- `frontend/src/pages/HighlightQueue.module.css` — Modified: styles for template config panel
+- `alembic/versions/027_add_captions_enabled.py` — New: migration adding captions_enabled boolean to generated_shorts
+- `alembic/versions/028_add_shorts_template.py` — New: migration adding shorts_template JSONB to creators
diff --git a/.gsd/milestones/M024/slices/S04/S04-UAT.md b/.gsd/milestones/M024/slices/S04/S04-UAT.md
new file mode 100644
index 0000000..44873f6
--- /dev/null
+++ b/.gsd/milestones/M024/slices/S04/S04-UAT.md
@@ -0,0 +1,89 @@
+# S04: [B] Auto-Captioning + Template System — UAT
+
+**Milestone:** M024
+**Written:** 2026-04-04T11:27:20.687Z
+
+# S04 UAT: Auto-Captioning + Template System
+
+## Preconditions
+- Backend running with migrations 027 and 028 applied
+- Frontend built and served
+- At least one creator with processed videos and highlight candidates in the system
+- ffmpeg available on the worker container
+
+## Test 1: Caption Generator Unit Tests
+1. Run `cd backend && python -m pytest pipeline/test_caption_generator.py -v`
+2. **Expected:** 17/17 tests pass covering:
+ - Time formatting (zero, sub-second, minutes, hours, negative clamping)
+ - ASS structure (header sections, dialogue lines, karaoke tags)
+ - Clip offset math (word at t=10.5 with clip_start=10.0 → t=0.5)
+ - Empty/whitespace word handling (skipped)
+ - Custom style overrides applied to ASS header
+
+## Test 2: Card Renderer Unit Tests
+1. Run `cd backend && python -m pytest pipeline/test_card_renderer.py -v`
+2. **Expected:** 28/28 tests pass covering:
+ - render_card returns valid ffmpeg lavfi command with correct dimensions
+ - Silent audio track (anullsrc) included for concat compatibility
+ - Validation rejects zero/negative duration and dimensions
+ - Colon escaping in drawtext filter text
+ - concat_segments builds correct demuxer file list
+ - parse_template_config fills defaults for null/missing/partial configs
+ - extract_clip_with_template delegates correctly with 0/1/2 cards
+
+## Test 3: Frontend Build
+1. Run `cd frontend && npm run build`
+2. **Expected:** Clean build with exit code 0. HighlightQueue chunk present in dist/.
+
+## Test 4: Module Imports
+1. Run `cd backend && python -c "from pipeline.caption_generator import generate_ass_captions, write_ass_file; print('ok')"`
+2. Run `cd backend && python -c "from pipeline.card_renderer import render_card, render_card_to_file, concat_segments, parse_template_config; print('ok')"`
+3. Run `cd backend && python -c "from routers.creators import router, admin_router; print('ok')"`
+4. **Expected:** All print 'ok' without import errors.
+
+## Test 5: Admin Template API (requires running API)
+1. `PUT /api/v1/admin/creators/{id}/shorts-template` with body:
+ ```json
+ {"intro_text": "Test Intro", "outro_text": "Test Outro", "accent_color": "#ff0000", "intro_duration_secs": 2.0, "outro_duration_secs": 3.0, "show_intro": true, "show_outro": true}
+ ```
+2. **Expected:** 200 OK, template saved.
+3. `GET /api/v1/admin/creators/{id}/shorts-template`
+4. **Expected:** Returns the saved template config with all fields matching.
+
+## Test 6: Template Config Validation
+1. `PUT /api/v1/admin/creators/{id}/shorts-template` with `intro_duration_secs: 10.0`
+2. **Expected:** 422 validation error (max 5.0).
+3. `PUT` with `accent_color: "not-hex"`
+4. **Expected:** 422 validation error (hex pattern required).
+
+## Test 7: Template Config UI on HighlightQueue
+1. Navigate to /admin/highlights (HighlightQueue page).
+2. Select a creator from the dropdown.
+3. Click "Shorts Template" section header.
+4. **Expected:** Collapsible panel expands showing intro text, outro text, accent color picker, duration sliders (1-5s range), show intro/outro toggles, font family input, and Save button.
+5. Fill in values and click Save.
+6. **Expected:** Template saves successfully (toast or visual confirmation).
+7. Reload page, select same creator.
+8. **Expected:** Previously saved values are pre-filled.
+
+## Test 8: Captions Toggle on Short Generation
+1. On HighlightQueue, select a highlight candidate.
+2. Verify a "Captions" checkbox is visible next to the Generate Shorts button.
+3. **Expected:** Checkbox defaults to checked (captions enabled).
+4. Uncheck the captions checkbox and trigger generation.
+5. **Expected:** The generated short has `captions_enabled = false` in the database.
+
+## Test 9: Non-Blocking Caption Failure
+1. Process a short from a video with no word-level timings in the transcript.
+2. **Expected:** Short generates successfully without captions. Worker logs show WARNING about missing word timings. `captions_enabled` is false on the generated short record.
+
+## Test 10: Non-Blocking Card Render Failure
+1. Set a creator template with `show_intro: true`.
+2. Trigger short generation on a system where the configured font is unavailable.
+3. **Expected:** Short generates. If card rendering fails, worker logs WARNING and short proceeds without intro/outro. The main clip content is preserved.
+
+## Edge Cases
+- **No template configured:** Shorts generate as before (no intro/outro cards). No errors.
+- **Template with show_intro=false, show_outro=false:** Equivalent to no template. Main clip only.
+- **Empty intro_text:** Card renders with blank text area (valid, just a color card with accent line).
+- **Creator with no highlights:** Template config panel still loads and saves independently of highlight state.
diff --git a/.gsd/milestones/M024/slices/S04/tasks/T03-VERIFY.json b/.gsd/milestones/M024/slices/S04/tasks/T03-VERIFY.json
new file mode 100644
index 0000000..f85cd18
--- /dev/null
+++ b/.gsd/milestones/M024/slices/S04/tasks/T03-VERIFY.json
@@ -0,0 +1,24 @@
+{
+ "schemaVersion": 1,
+ "taskId": "T03",
+ "unitId": "M024/S04/T03",
+ "timestamp": 1775301929918,
+ "passed": false,
+ "discoverySource": "task-plan",
+ "checks": [
+ {
+ "command": "cd frontend",
+ "exitCode": 0,
+ "durationMs": 8,
+ "verdict": "pass"
+ },
+ {
+ "command": "cd ../backend",
+ "exitCode": 2,
+ "durationMs": 8,
+ "verdict": "fail"
+ }
+ ],
+ "retryAttempt": 1,
+ "maxRetries": 2
+}
diff --git a/.gsd/milestones/M024/slices/S05/S05-PLAN.md b/.gsd/milestones/M024/slices/S05/S05-PLAN.md
index a2f9925..ecab934 100644
--- a/.gsd/milestones/M024/slices/S05/S05-PLAN.md
+++ b/.gsd/milestones/M024/slices/S05/S05-PLAN.md
@@ -1,6 +1,38 @@
# S05: [B] Citation UX Improvements
-**Goal:** Polish chat citation UX with timestamp deep links and visual source cards
+**Goal:** Chat citations link to video timestamps and source cards display video metadata (filename, formatted timestamp badge) for key_moment sources.
**Demo:** After this: Chat citations show timestamp links that seek the player and source cards with video thumbnails
## Tasks
+- [x] **T01: Added source_video_id, start_time, end_time, and video_filename to Qdrant enrichment, keyword search, and chat SSE source events for key_moment results** — The Qdrant payloads for key_moments already store `source_video_id`, `start_time`, `end_time` — but `_enrich_qdrant_results()` in search_service.py drops them. The keyword search path also omits them from result dicts. And `_build_sources()` in chat_service.py doesn't include them in the SSE event. This task adds the video fields at all three points.
+
+## Steps
+
+1. In `backend/search_service.py` `_enrich_qdrant_results()` (~line 1163), add these fields to the enriched dict for key_moment type results: `source_video_id` from `payload.get('source_video_id', '')`, `start_time` from `payload.get('start_time', None)`, `end_time` from `payload.get('end_time', None)`. Also batch-fetch SourceVideo filenames for key_moment results: collect `source_video_id` values, query SourceVideo table, build a `{video_id: filename}` map, and add `video_filename` to each enriched result.
+
+2. In `backend/search_service.py` `_keyword_search_and()` (~line 337), the key_moment result dict already joins SourceVideo. Add `source_video_id: str(km.source_video_id)`, `start_time: km.start_time`, `end_time: km.end_time`, `video_filename: sv.filename` to the dict.
+
+3. In `backend/chat_service.py` `_build_sources()` (~line 248), add `source_video_id`, `start_time`, `end_time`, `video_filename` to the source dict, pulling from the item when present (they'll be None/empty for technique_page results, which is fine).
+
+4. Verify: `python -c "from chat_service import _build_sources; print('OK')"` imports cleanly. `python -c "from search_service import SearchService; print('OK')"` imports cleanly.
+ - Estimate: 30m
+ - Files: backend/search_service.py, backend/chat_service.py
+ - Verify: cd backend && python -c "from chat_service import _build_sources; print('OK')" && python -c "from search_service import SearchService; print('OK')"
+- [ ] **T02: Timestamp links and enhanced source cards in ChatPage, ChatWidget, and shared citation utility** — Extend the frontend ChatSource type with video fields, update both ChatPage and ChatWidget source cards to show timestamp badges that link to the WatchPage, and consolidate the duplicated citation parser into a shared utility.
+
+## Steps
+
+1. **Extend ChatSource type** in `frontend/src/api/chat.ts`: add optional fields `source_video_id?: string`, `start_time?: number`, `end_time?: number`, `video_filename?: string`.
+
+2. **Create shared chat citation parser** in `frontend/src/utils/chatCitations.tsx`: Extract the common `parseChatCitations(text, sources)` function that both ChatPage and ChatWidget duplicate. It takes `(text: string, sources: ChatSource[])` and returns `React.ReactNode[]`. The function parses `[N]` markers into superscript `` elements to `/techniques/{slug}#{section_anchor}` or `/techniques/{slug}`. Import and use in both ChatPage and ChatWidget, removing their local copies. Keep the existing `citations.tsx` (it handles technique-page key moment anchors, different interface).
+
+3. **Add `formatTime` utility** in `frontend/src/utils/formatTime.ts`: Extract the duplicated `formatTime(seconds: number): string` function (currently copy-pasted in 4 files). Format: `M:SS` for < 1hr, `H:MM:SS` for >= 1hr.
+
+4. **Update ChatPage source cards** in `frontend/src/pages/ChatPage.tsx`: In the source list rendering (~line 262), when `src.start_time` is defined, show a timestamp badge linking to `/watch/${src.source_video_id}?t=${src.start_time}`. Display the formatted time range. Show `src.video_filename` as subtle metadata when present. Add CSS classes `.timestampBadge` and `.videoMeta` to `ChatPage.module.css`.
+
+5. **Update ChatWidget source cards** in `frontend/src/components/ChatWidget.tsx`: Same timestamp badge pattern as ChatPage but using the compact widget styles. Replace the local `parseCitations` with the shared import. Add CSS classes `.timestampBadge` and `.videoMeta` to `ChatWidget.module.css`.
+
+6. **Verify**: `cd frontend && npm run build` passes with zero errors. Both ChatPage and ChatWidget import from the shared citation utility. The old local `parseCitations`/`parseChatCitations` functions are removed from both files.
+ - Estimate: 45m
+ - Files: frontend/src/api/chat.ts, frontend/src/utils/chatCitations.tsx, frontend/src/utils/formatTime.ts, frontend/src/pages/ChatPage.tsx, frontend/src/pages/ChatPage.module.css, frontend/src/components/ChatWidget.tsx, frontend/src/components/ChatWidget.module.css
+ - Verify: cd frontend && npm run build 2>&1 | tail -5
diff --git a/.gsd/milestones/M024/slices/S05/S05-RESEARCH.md b/.gsd/milestones/M024/slices/S05/S05-RESEARCH.md
new file mode 100644
index 0000000..432fb67
--- /dev/null
+++ b/.gsd/milestones/M024/slices/S05/S05-RESEARCH.md
@@ -0,0 +1,75 @@
+# S05 Research — Citation UX Improvements
+
+## Summary
+
+This slice upgrades chat citations from plain technique-page links to timestamp-aware links that can seek a video player, and enriches source cards with video metadata. The scope is a vertical pass through backend (search → chat_service → SSE) and frontend (ChatPage, ChatWidget, chat.ts types).
+
+**Calibration: Targeted research.** Known patterns, known codebase — the main question is data plumbing (what fields exist where) and how to handle the "video thumbnails" aspect given no thumbnail infrastructure exists.
+
+## Recommendation
+
+### Approach
+1. **Enrich ChatSource with video metadata** — add `source_video_id`, `start_time`, `end_time`, `video_filename` to the SSE `sources` event. Backend `_build_sources()` in `chat_service.py` already receives search result items; key_moment items from Qdrant payloads include these fields. Keyword search results for key_moments need the fields added too.
+2. **Frontend: timestamp links in source cards** — when a source has `start_time`, render a formatted timestamp link. Two behaviors:
+ - On ChatPage (standalone): link to `/watch/{source_video_id}?t={start_time}` (existing WatchPage route handles `?t=` param).
+ - On ChatWidget (embedded in TechniquePage): if the source's `source_video_id` matches the active video, call `seekInlinePlayer(start_time)` instead of navigating away.
+3. **Video thumbnails: use placeholder approach** — no ffmpeg or thumbnail generation exists in the stack. Instead of adding infrastructure for thumbnails, enhance source cards with a visual treatment using the video filename, timestamp badge, and topic_category color coding. This matches the "low risk" slice designation. A future slice can add real thumbnails via ffmpeg frame extraction.
+4. **Deduplicate parseCitations** — the same citation-parsing logic is copy-pasted in 3 places (ChatPage, ChatWidget, utils/citations.tsx). Consolidate into a single shared utility.
+
+### Why not real thumbnails
+- No ffmpeg in the Docker image or requirements
+- No thumbnail generation pipeline exists
+- SourceVideo model has no thumbnail field
+- Adding all this exceeds "low risk" scope
+- A styled source card with timestamp badge + video filename achieves the visual improvement goal
+
+## Implementation Landscape
+
+### Files to modify
+
+**Backend:**
+- `backend/chat_service.py` — `_build_sources()`: propagate `source_video_id`, `start_time`, `end_time`, `video_filename` from search items when the item type is `key_moment`. For technique_page items, resolve their first key moment's video info (optional enrichment).
+- `backend/search_service.py` — `_keyword_search_and()`: add `start_time`, `end_time`, `source_video_id`, `video_filename` (from joined SourceVideo) to key_moment result dicts. Currently missing these fields. Also check `_enrich_qdrant_results()` to ensure Qdrant key_moment payloads pass `start_time`/`end_time`/`source_video_id` through.
+
+**Frontend types:**
+- `frontend/src/api/chat.ts` — extend `ChatSource` interface with `source_video_id?: string`, `start_time?: number`, `end_time?: number`, `video_filename?: string`.
+
+**Frontend components:**
+- `frontend/src/pages/ChatPage.tsx` — update source card rendering to show timestamp link when `start_time` is present. Link to `/watch/{source_video_id}?t={start_time}`.
+- `frontend/src/components/ChatWidget.tsx` — update source card rendering. When `source_video_id` matches the active video and player is open, emit a seek event instead of navigating. Otherwise link to `/watch/...?t=...`.
+- `frontend/src/pages/ChatPage.module.css` — add styles for timestamp badge in source cards, enhanced source card layout with video metadata.
+- `frontend/src/components/ChatWidget.module.css` — same timestamp badge styles adapted for compact widget.
+
+**Citation consolidation:**
+- `frontend/src/utils/citations.tsx` — already exists but only handles technique-page key_moments (anchor links to `#km-{id}`). The chat citation parsers in ChatPage and ChatWidget are separate copies that handle ChatSource (links to `/techniques/{slug}`). Consolidate the chat-specific parser into a shared utility, or at minimum extract the common logic.
+
+### Data flow
+
+```
+Search results (keyword or Qdrant)
+ → items[] with {type, source_video_id, start_time, ...}
+ → chat_service._build_sources() picks up video fields
+ → SSE event: sources [{..., source_video_id, start_time, end_time, video_filename}]
+ → Frontend ChatSource type extended
+ → Source card renders timestamp link
+```
+
+### Key seams (task boundaries)
+
+1. **Backend: enrich search items + ChatSource SSE** — search_service.py keyword results + chat_service.py _build_sources(). Pure data plumbing. Verifiable with curl against chat endpoint.
+2. **Frontend types + source card UI** — chat.ts types + ChatPage + ChatWidget source card rendering. Verifiable with build + visual inspection.
+3. **Citation parser consolidation** — extract shared chat citation parser. Verifiable with build (no behavior change).
+
+### Existing patterns to follow
+
+- `TechniquePage.tsx` lines 618-640: key moment rendering with `seekInlinePlayer()` for active video, `Link to="/watch/..."` for others — exact same pattern needed in ChatWidget.
+- `formatTime()` utility: duplicated in 4 files. Could extract but that's optional cleanup.
+- `ChatWidget` receives `creatorName` and `techniques` as props but NOT `activeVideoId` or `seekInlinePlayer`. To support seek-from-chat, ChatWidget needs either: (a) a callback prop, or (b) to emit a custom event that TechniquePage listens for. Option (a) is cleaner.
+
+### What to verify
+
+- `npm run build` passes with zero errors
+- Chat endpoint returns enriched sources with video fields (curl or integration test)
+- ChatPage source cards show timestamp links for key_moment sources
+- ChatWidget source cards show timestamp links
+- Existing citation [N] superscript links still work in both ChatPage and ChatWidget
diff --git a/.gsd/milestones/M024/slices/S05/tasks/T01-PLAN.md b/.gsd/milestones/M024/slices/S05/tasks/T01-PLAN.md
new file mode 100644
index 0000000..618074d
--- /dev/null
+++ b/.gsd/milestones/M024/slices/S05/tasks/T01-PLAN.md
@@ -0,0 +1,33 @@
+---
+estimated_steps: 6
+estimated_files: 2
+skills_used: []
+---
+
+# T01: Propagate video metadata through search → chat SSE pipeline
+
+The Qdrant payloads for key_moments already store `source_video_id`, `start_time`, `end_time` — but `_enrich_qdrant_results()` in search_service.py drops them. The keyword search path also omits them from result dicts. And `_build_sources()` in chat_service.py doesn't include them in the SSE event. This task adds the video fields at all three points.
+
+## Steps
+
+1. In `backend/search_service.py` `_enrich_qdrant_results()` (~line 1163), add these fields to the enriched dict for key_moment type results: `source_video_id` from `payload.get('source_video_id', '')`, `start_time` from `payload.get('start_time', None)`, `end_time` from `payload.get('end_time', None)`. Also batch-fetch SourceVideo filenames for key_moment results: collect `source_video_id` values, query SourceVideo table, build a `{video_id: filename}` map, and add `video_filename` to each enriched result.
+
+2. In `backend/search_service.py` `_keyword_search_and()` (~line 337), the key_moment result dict already joins SourceVideo. Add `source_video_id: str(km.source_video_id)`, `start_time: km.start_time`, `end_time: km.end_time`, `video_filename: sv.filename` to the dict.
+
+3. In `backend/chat_service.py` `_build_sources()` (~line 248), add `source_video_id`, `start_time`, `end_time`, `video_filename` to the source dict, pulling from the item when present (they'll be None/empty for technique_page results, which is fine).
+
+4. Verify: `python -c "from chat_service import _build_sources; print('OK')"` imports cleanly. `python -c "from search_service import SearchService; print('OK')"` imports cleanly.
+
+## Inputs
+
+- ``backend/search_service.py` — _enrich_qdrant_results() and _keyword_search_and() methods`
+- ``backend/chat_service.py` — _build_sources() function`
+
+## Expected Output
+
+- ``backend/search_service.py` — enriched results include source_video_id, start_time, end_time, video_filename for key_moment items`
+- ``backend/chat_service.py` — _build_sources() includes video fields in SSE source dicts`
+
+## Verification
+
+cd backend && python -c "from chat_service import _build_sources; print('OK')" && python -c "from search_service import SearchService; print('OK')"
diff --git a/.gsd/milestones/M024/slices/S05/tasks/T01-SUMMARY.md b/.gsd/milestones/M024/slices/S05/tasks/T01-SUMMARY.md
new file mode 100644
index 0000000..220fa77
--- /dev/null
+++ b/.gsd/milestones/M024/slices/S05/tasks/T01-SUMMARY.md
@@ -0,0 +1,78 @@
+---
+id: T01
+parent: S05
+milestone: M024
+provides: []
+requires: []
+affects: []
+key_files: ["backend/search_service.py", "backend/chat_service.py"]
+key_decisions: ["Batch-fetch SourceVideo filenames using same pattern as creator batch-fetch", "Video fields are empty/None for non-key_moment types to keep dict shape uniform"]
+patterns_established: []
+drill_down_paths: []
+observability_surfaces: []
+duration: ""
+verification_result: "Both modules import cleanly: python -c "from chat_service import _build_sources; print('OK')" and python -c "from search_service import SearchService; print('OK')" both exit 0."
+completed_at: 2026-04-04T11:40:55.138Z
+blocker_discovered: false
+---
+
+# T01: Added source_video_id, start_time, end_time, and video_filename to Qdrant enrichment, keyword search, and chat SSE source events for key_moment results
+
+> Added source_video_id, start_time, end_time, and video_filename to Qdrant enrichment, keyword search, and chat SSE source events for key_moment results
+
+## What Happened
+---
+id: T01
+parent: S05
+milestone: M024
+key_files:
+ - backend/search_service.py
+ - backend/chat_service.py
+key_decisions:
+ - Batch-fetch SourceVideo filenames using same pattern as creator batch-fetch
+ - Video fields are empty/None for non-key_moment types to keep dict shape uniform
+duration: ""
+verification_result: passed
+completed_at: 2026-04-04T11:40:55.139Z
+blocker_discovered: false
+---
+
+# T01: Added source_video_id, start_time, end_time, and video_filename to Qdrant enrichment, keyword search, and chat SSE source events for key_moment results
+
+**Added source_video_id, start_time, end_time, and video_filename to Qdrant enrichment, keyword search, and chat SSE source events for key_moment results**
+
+## What Happened
+
+Three code paths needed video metadata propagated: _enrich_qdrant_results() got batch-fetch of SourceVideo filenames plus four video fields in the enriched dict, _keyword_search_and() got the same four fields from the already-joined SourceVideo, and _build_sources() in chat_service.py passes them through to the SSE source events.
+
+## Verification
+
+Both modules import cleanly: python -c "from chat_service import _build_sources; print('OK')" and python -c "from search_service import SearchService; print('OK')" both exit 0.
+
+## Verification Evidence
+
+| # | Command | Exit Code | Verdict | Duration |
+|---|---------|-----------|---------|----------|
+| 1 | `cd backend && python -c "from chat_service import _build_sources; print('OK')"` | 0 | ✅ pass | 500ms |
+| 2 | `cd backend && python -c "from search_service import SearchService; print('OK')"` | 0 | ✅ pass | 500ms |
+
+
+## Deviations
+
+Fixed operator precedence bug: (sv.filename or "") if sv else "" needed parens.
+
+## Known Issues
+
+None.
+
+## Files Created/Modified
+
+- `backend/search_service.py`
+- `backend/chat_service.py`
+
+
+## Deviations
+Fixed operator precedence bug: (sv.filename or "") if sv else "" needed parens.
+
+## Known Issues
+None.
diff --git a/.gsd/milestones/M024/slices/S05/tasks/T02-PLAN.md b/.gsd/milestones/M024/slices/S05/tasks/T02-PLAN.md
new file mode 100644
index 0000000..062d4d8
--- /dev/null
+++ b/.gsd/milestones/M024/slices/S05/tasks/T02-PLAN.md
@@ -0,0 +1,46 @@
+---
+estimated_steps: 8
+estimated_files: 7
+skills_used: []
+---
+
+# T02: Timestamp links and enhanced source cards in ChatPage, ChatWidget, and shared citation utility
+
+Extend the frontend ChatSource type with video fields, update both ChatPage and ChatWidget source cards to show timestamp badges that link to the WatchPage, and consolidate the duplicated citation parser into a shared utility.
+
+## Steps
+
+1. **Extend ChatSource type** in `frontend/src/api/chat.ts`: add optional fields `source_video_id?: string`, `start_time?: number`, `end_time?: number`, `video_filename?: string`.
+
+2. **Create shared chat citation parser** in `frontend/src/utils/chatCitations.tsx`: Extract the common `parseChatCitations(text, sources)` function that both ChatPage and ChatWidget duplicate. It takes `(text: string, sources: ChatSource[])` and returns `React.ReactNode[]`. The function parses `[N]` markers into superscript `` elements to `/techniques/{slug}#{section_anchor}` or `/techniques/{slug}`. Import and use in both ChatPage and ChatWidget, removing their local copies. Keep the existing `citations.tsx` (it handles technique-page key moment anchors, different interface).
+
+3. **Add `formatTime` utility** in `frontend/src/utils/formatTime.ts`: Extract the duplicated `formatTime(seconds: number): string` function (currently copy-pasted in 4 files). Format: `M:SS` for < 1hr, `H:MM:SS` for >= 1hr.
+
+4. **Update ChatPage source cards** in `frontend/src/pages/ChatPage.tsx`: In the source list rendering (~line 262), when `src.start_time` is defined, show a timestamp badge linking to `/watch/${src.source_video_id}?t=${src.start_time}`. Display the formatted time range. Show `src.video_filename` as subtle metadata when present. Add CSS classes `.timestampBadge` and `.videoMeta` to `ChatPage.module.css`.
+
+5. **Update ChatWidget source cards** in `frontend/src/components/ChatWidget.tsx`: Same timestamp badge pattern as ChatPage but using the compact widget styles. Replace the local `parseCitations` with the shared import. Add CSS classes `.timestampBadge` and `.videoMeta` to `ChatWidget.module.css`.
+
+6. **Verify**: `cd frontend && npm run build` passes with zero errors. Both ChatPage and ChatWidget import from the shared citation utility. The old local `parseCitations`/`parseChatCitations` functions are removed from both files.
+
+## Inputs
+
+- ``frontend/src/api/chat.ts` — existing ChatSource interface`
+- ``frontend/src/pages/ChatPage.tsx` — source card rendering and local parseChatCitations`
+- ``frontend/src/components/ChatWidget.tsx` — source card rendering and local parseCitations`
+- ``frontend/src/pages/ChatPage.module.css` — existing source card styles`
+- ``frontend/src/components/ChatWidget.module.css` — existing source card styles`
+- ``backend/chat_service.py` — T01 output: SSE sources now include source_video_id, start_time, end_time, video_filename`
+
+## Expected Output
+
+- ``frontend/src/api/chat.ts` — ChatSource extended with video fields`
+- ``frontend/src/utils/chatCitations.tsx` — shared chat citation parser extracted from ChatPage/ChatWidget`
+- ``frontend/src/utils/formatTime.ts` — shared formatTime utility`
+- ``frontend/src/pages/ChatPage.tsx` — uses shared citation parser, source cards show timestamp links`
+- ``frontend/src/pages/ChatPage.module.css` — timestamp badge and video meta styles`
+- ``frontend/src/components/ChatWidget.tsx` — uses shared citation parser, source cards show timestamp links`
+- ``frontend/src/components/ChatWidget.module.css` — timestamp badge and video meta styles`
+
+## Verification
+
+cd frontend && npm run build 2>&1 | tail -5
diff --git a/backend/chat_service.py b/backend/chat_service.py
index 8e8d144..e791d8e 100644
--- a/backend/chat_service.py
+++ b/backend/chat_service.py
@@ -258,6 +258,10 @@ def _build_sources(items: list[dict[str, Any]]) -> list[dict[str, str]]:
"summary": (item.get("summary", "") or "")[:200],
"section_anchor": item.get("section_anchor", ""),
"section_heading": item.get("section_heading", ""),
+ "source_video_id": item.get("source_video_id", ""),
+ "start_time": item.get("start_time"),
+ "end_time": item.get("end_time"),
+ "video_filename": item.get("video_filename", ""),
})
return sources
diff --git a/backend/search_service.py b/backend/search_service.py
index 6b1a000..fa2ec33 100644
--- a/backend/search_service.py
+++ b/backend/search_service.py
@@ -346,6 +346,10 @@ class SearchService:
"creator_slug": cr.slug,
"created_at": km.created_at.isoformat() if hasattr(km, "created_at") and km.created_at else "",
"score": 0.0,
+ "source_video_id": str(km.source_video_id) if km.source_video_id else "",
+ "start_time": km.start_time,
+ "end_time": km.end_time,
+ "video_filename": (sv.filename or "") if sv else "",
})
if scope in ("all", "creators"):
@@ -1118,6 +1122,13 @@ class SearchService:
if not payload.get("creator_name") and payload.get("creator_id"):
needs_db_lookup.add(payload["creator_id"])
+ # Collect source_video_ids for key_moment results to batch-fetch filenames
+ video_ids_needed: set[str] = set()
+ for r in qdrant_results:
+ payload = r.get("payload", {})
+ if payload.get("type") == "key_moment" and payload.get("source_video_id"):
+ video_ids_needed.add(payload["source_video_id"])
+
# Batch fetch creators from DB
creator_map: dict[str, dict[str, str]] = {}
if needs_db_lookup:
@@ -1133,6 +1144,21 @@ class SearchService:
for c in result.scalars().all():
creator_map[str(c.id)] = {"name": c.name, "slug": c.slug}
+ # Batch fetch video filenames for key_moment results
+ video_map: dict[str, str] = {}
+ if video_ids_needed:
+ valid_vids = []
+ for vid in video_ids_needed:
+ try:
+ valid_vids.append(uuid_mod.UUID(vid))
+ except (ValueError, AttributeError):
+ pass
+ if valid_vids:
+ v_stmt = select(SourceVideo).where(SourceVideo.id.in_(valid_vids))
+ v_result = await db.execute(v_stmt)
+ for sv in v_result.scalars().all():
+ video_map[str(sv.id)] = sv.filename or ""
+
for r in qdrant_results:
payload = r.get("payload", {})
cid = payload.get("creator_id", "")
@@ -1174,6 +1200,10 @@ class SearchService:
"match_context": "",
"section_anchor": payload.get("section_anchor", "") if result_type == "technique_section" else "",
"section_heading": payload.get("section_heading", "") if result_type == "technique_section" else "",
+ "source_video_id": payload.get("source_video_id", "") if result_type == "key_moment" else "",
+ "start_time": payload.get("start_time") if result_type == "key_moment" else None,
+ "end_time": payload.get("end_time") if result_type == "key_moment" else None,
+ "video_filename": video_map.get(payload.get("source_video_id", ""), "") if result_type == "key_moment" else "",
})
return enriched