feat: Add creator-scoped highlight review endpoints (list/detail/status…

- "backend/models.py"
- "alembic/versions/021_add_highlight_trim_columns.py"
- "backend/routers/creator_highlights.py"
- "backend/main.py"

GSD-Task: S01/T01
This commit is contained in:
jlightner 2026-04-04 06:58:28 +00:00
parent 29c2a58843
commit c05e4da594
13 changed files with 15559 additions and 21 deletions

View file

@ -0,0 +1 @@
[]

View file

@ -1,6 +1,124 @@
# S01: [A] Highlight Reel + Shorts Queue UI
**Goal:** Build highlight reel and shorts queue review UI for creators
**Goal:** Creator reviews auto-detected highlights and short candidates in a review queue — approve, trim, discard
**Demo:** After this: Creator reviews auto-detected highlights and short candidates in a review queue — approve, trim, discard
## Tasks
- [x] **T01: Add creator-scoped highlight review endpoints (list/detail/status/trim) with Alembic migration for trim columns** — Build the backend foundation: a new FastAPI router with creator-scoped highlight endpoints, an Alembic migration for trim columns, and model updates. Follows the `creator_chapters.py` auth/ownership pattern exactly.
## Steps
1. Add `trim_start` (nullable Float) and `trim_end` (nullable Float) columns to `HighlightCandidate` in `backend/models.py` (after `status` field, before `created_at`)
2. Create Alembic migration `alembic/versions/021_add_highlight_trim_columns.py` that adds `trim_start` and `trim_end` nullable float columns to `highlight_candidates` table
3. Create `backend/routers/creator_highlights.py` with these endpoints:
- `GET /creator/highlights` — list highlights for creator's videos, with optional `?status=` and `?shorts_only=true` (duration_secs ≤ 60) query filters. Eager-load `key_moment` relationship for title/start_time/end_time. Order by score DESC.
- `GET /creator/highlights/{highlight_id}` — single highlight detail with full score_breakdown and key_moment info. Verify creator owns the source video.
- `PATCH /creator/highlights/{highlight_id}` — update status (approve/reject). Accept `{"status": "approved"|"rejected"}` body. Verify creator ownership. Map UI "discard" to `rejected` status to avoid enum migration.
- `PATCH /creator/highlights/{highlight_id}/trim` — set trim_start/trim_end. Accept `{"trim_start": float, "trim_end": float}` body. Validate: both non-negative, trim_start < trim_end. Verify creator ownership.
4. Use Pydantic schemas for request/response: create `HighlightListResponse`, `HighlightDetailResponse`, `HighlightStatusUpdate`, `HighlightTrimUpdate` in the router file (or a separate schemas file). Reuse `HighlightScoreBreakdown` from `backend/pipeline/highlight_schemas.py`.
5. Auth pattern: `current_user: User = Depends(get_current_user)`, then filter `SourceVideo.creator_id == current_user.creator_id`. Reuse `_verify_creator_owns_video` helper or create equivalent.
6. Register the router in `backend/main.py`: add `creator_highlights` to the import line and `app.include_router(creator_highlights.router, prefix="/api/v1")`
7. Add `chrysopedia.creator_highlights` logger with info-level messages on status changes and trim actions.
## Failure Modes
| Dependency | On error | On timeout | On malformed response |
|------------|----------|-----------|----------------------|
| PostgreSQL | Return 500 with logged traceback | SQLAlchemy handles connection pool timeout | N/A (ORM) |
| Auth (JWT) | Return 401 via get_current_user | N/A | Return 401 |
## Negative Tests
- **Malformed inputs**: PATCH with invalid status string → 422; trim with negative values or trim_start >= trim_end → 400
- **Error paths**: No creator profile → 403; highlight not found → 404; highlight belongs to different creator → 404
- **Boundary conditions**: Empty highlight list returns `[]`; shorts_only with no qualifying highlights returns `[]`
## Must-Haves
- [ ] `trim_start` and `trim_end` nullable Float columns on HighlightCandidate model
- [ ] Alembic migration 021 applies cleanly
- [ ] GET /creator/highlights returns creator-scoped results with status/shorts filters
- [ ] GET /creator/highlights/{id} returns detail with score_breakdown and key_moment
- [ ] PATCH /creator/highlights/{id} changes status with ownership verification
- [ ] PATCH /creator/highlights/{id}/trim sets trim window with validation
- [ ] Router registered in main.py
## Verification
- `cd /home/aux/projects/content-to-kb-automator && python -c "from routers.creator_highlights import router; print('Router imported OK')"` (run from backend dir)
- `cd /home/aux/projects/content-to-kb-automator && python -c "from models import HighlightCandidate; assert hasattr(HighlightCandidate, 'trim_start'); assert hasattr(HighlightCandidate, 'trim_end'); print('Model columns OK')"`
- `grep -q 'creator_highlights' backend/main.py && echo 'Router registered'`
- `python -c "import ast; ast.parse(open('alembic/versions/021_add_highlight_trim_columns.py').read()); print('Migration syntax OK')"`
## Observability Impact
- Signals added: `chrysopedia.creator_highlights` logger with info-level status transition and trim action messages
- Inspection: `GET /api/v1/creator/highlights?status=approved` to check approved highlights; DB query on `highlight_candidates` table
- Failure state: 403/404 errors with specific detail messages logged at warning level
- Estimate: 1h
- Files: backend/models.py, alembic/versions/021_add_highlight_trim_columns.py, backend/routers/creator_highlights.py, backend/main.py
- Verify: cd /home/aux/projects/content-to-kb-automator && python -c "import sys; sys.path.insert(0,'backend'); from models import HighlightCandidate; assert hasattr(HighlightCandidate,'trim_start'); print('OK')" && grep -q creator_highlights backend/main.py && echo 'Registered'
- [ ] **T02: HighlightQueue page, API layer, route wiring, and sidebar link** — Build the complete frontend: TypeScript API layer, HighlightQueue page with filter tabs and action controls, CSS module, route registration in App.tsx, and Highlights link in SidebarNav. Follows the ChapterReview.tsx pattern for layout and CreatorDashboard SidebarNav for navigation.
## Steps
1. Create `frontend/src/api/highlights.ts` with:
- TypeScript interfaces: `HighlightCandidate` (id, key_moment_id, source_video_id, score, score_breakdown: ScoreBreakdown, duration_secs, status, trim_start, trim_end, created_at, key_moment: { title, start_time, end_time, content_type }), `ScoreBreakdown` (7 float fields matching backend), list response type
- API functions: `fetchCreatorHighlights(params?: { status?: string; shorts_only?: boolean })`, `fetchHighlightDetail(id: string)`, `updateHighlightStatus(id: string, status: string)`, `trimHighlight(id: string, trim_start: number, trim_end: number)`
- Use `request<T>()` and `BASE` from `./client`
2. Create `frontend/src/pages/HighlightQueue.module.css` following ChapterReview.module.css patterns:
- Layout: `.pageLayout` (sidebar + content grid), `.content`, `.header`
- Filter tabs: `.filterTabs`, `.filterTab`, `.filterTabActive`
- Cards: `.candidateCard`, `.candidateScore`, `.candidateTitle`, `.candidateDuration`, `.candidateStatus`
- Score breakdown bars: `.scoreBar`, `.scoreBarFill`, `.scoreLabel`
- Action buttons: `.actionBtn`, `.approveBtn`, `.rejectBtn`, `.trimBtn` — reuse CSS custom properties `--color-btn-approve`, `--color-btn-reject`
- Status badges: reuse `--color-badge-approved-*`, `--color-badge-rejected-*` custom property patterns
- Trim UI: `.trimPanel`, `.trimInput`
- Empty state: `.emptyState`
- Shorts badge: `.shortsBadge` (small pill for ≤60s candidates)
3. Create `frontend/src/pages/HighlightQueue.tsx`:
- Import `SidebarNav` from `./CreatorDashboard`
- State: highlights array, loading boolean, active filter tab (all/shorts/approved/rejected), optional expanded highlight ID for trim UI
- On mount: fetch highlights from API, apply filter based on active tab
- Filter tabs: All | Shorts (≤60s) | Approved | Rejected — clicking a tab re-fetches with appropriate query params
- Candidate cards showing: key_moment title, formatted duration, composite score as percentage bar, content_type badge, status badge
- Score breakdown section: 7 horizontal bars (one per dimension) with labels and percentages
- Action buttons per card: Approve (green, calls updateHighlightStatus), Discard (red, calls updateHighlightStatus with 'rejected'), Trim (toggles inline trim panel)
- Trim panel: two number inputs (start seconds, end seconds) with Save/Cancel buttons. Pre-fill with current key_moment start_time/end_time if no trim exists. On save, call trimHighlight API.
- Empty state message when no highlights match current filter
- Loading spinner during fetch
- Error handling: catch ApiError, show user-friendly message
4. Add lazy import and route in `frontend/src/App.tsx`:
- Add `const HighlightQueue = React.lazy(() => import("./pages/HighlightQueue"));` with other lazy imports
- Add route: `<Route path="/creator/highlights" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><HighlightQueue /></Suspense></ProtectedRoute>} />`
- Place after the existing `/creator/chapters/:videoId` route
5. Add "Highlights" link to `SidebarNav` in `frontend/src/pages/CreatorDashboard.tsx`:
- Add a new NavLink entry with a star/sparkle SVG icon and text "Highlights" linking to `/creator/highlights`
- Place after the "Chapters" link in the nav list
## Must-Haves
- [ ] TypeScript API layer with all 4 functions matching backend endpoints
- [ ] HighlightQueue page renders with SidebarNav, filter tabs, candidate cards, score bars, and action buttons
- [ ] Filter tabs switch between All / Shorts / Approved / Rejected views
- [ ] Approve and Discard buttons call the status update API and refresh the list
- [ ] Trim panel opens inline with number inputs, validates, and calls trim API
- [ ] Route registered in App.tsx with ProtectedRoute and Suspense
- [ ] SidebarNav has "Highlights" link
- [ ] `npx tsc --noEmit` passes with zero errors
## Verification
- `cd /home/aux/projects/content-to-kb-automator/frontend && npx tsc --noEmit` — zero errors
- `test -f frontend/src/api/highlights.ts && echo 'API layer exists'`
- `test -f frontend/src/pages/HighlightQueue.tsx && echo 'Page exists'`
- `test -f frontend/src/pages/HighlightQueue.module.css && echo 'CSS exists'`
- `grep -q 'HighlightQueue' frontend/src/App.tsx && echo 'Route wired'`
- `grep -q 'Highlights' frontend/src/pages/CreatorDashboard.tsx && echo 'Sidebar link added'`
- Estimate: 2h
- Files: frontend/src/api/highlights.ts, frontend/src/pages/HighlightQueue.tsx, frontend/src/pages/HighlightQueue.module.css, frontend/src/App.tsx, frontend/src/pages/CreatorDashboard.tsx
- Verify: cd /home/aux/projects/content-to-kb-automator/frontend && npx tsc --noEmit && grep -q HighlightQueue src/App.tsx && grep -q Highlights src/pages/CreatorDashboard.tsx && echo 'All checks pass'

View file

@ -0,0 +1,125 @@
# S01 Research: Highlight Reel + Shorts Queue UI
## Summary
This slice builds a **creator-facing review queue** for highlight candidates and introduces a "shorts candidate" concept. The backend already has `HighlightCandidate` model, scoring engine, and admin-only API endpoints. The work is: (1) extend the backend with creator-scoped endpoints and a status action for approve/trim/discard, (2) build a new creator-facing page with queue UI, score visualization, and action controls, (3) introduce the "shorts candidate" concept (highlights with duration ≤60s are short-eligible).
**Depth: Targeted.** Known tech (React + FastAPI), moderate complexity in wiring existing models into a new creator-facing experience with trim/discard actions.
## Requirement Coverage
No new explicit requirements (R042+) exist for this slice. The slice is driven by the M022 roadmap vision: "Creator reviews auto-detected highlights and short candidates in a review queue — approve, trim, discard." This implicitly creates a new requirement surface.
## Recommendation
Follow the **ChapterReview pattern** exactly — it's the closest analogue (creator-scoped, video-based review queue with status transitions, waveform, sidebar nav). Reuse `SidebarNav` from CreatorDashboard, CSS module approach, and `api/client.ts` patterns.
## Implementation Landscape
### What Exists (Backend)
| Component | Location | What it does |
|-----------|----------|-------------|
| `HighlightCandidate` model | `backend/models.py:706-741` | SQLAlchemy model with `id`, `key_moment_id`, `source_video_id`, `score`, `score_breakdown` (JSONB), `duration_secs`, `status` (candidate/approved/rejected), timestamps |
| `HighlightStatus` enum | `backend/models.py:84-88` | `candidate`, `approved`, `rejected` — needs a `discarded` value if we want "discard" action distinct from "rejected" |
| `highlight_scorer.py` | `backend/pipeline/highlight_scorer.py` | 7-dimension heuristic scorer: duration, content_density, technique_relevance, position, uniqueness, engagement_proxy, plugin_diversity |
| `highlight_schemas.py` | `backend/pipeline/highlight_schemas.py` | Pydantic response schemas: `HighlightCandidateResponse`, `HighlightScoreBreakdown`, `HighlightBatchResult` |
| Admin highlights router | `backend/routers/highlights.py` | Admin-only endpoints: `POST /admin/highlights/detect/{video_id}`, `POST /admin/highlights/detect-all`, `GET /admin/highlights/candidates`, `GET /admin/highlights/candidates/{id}` |
| `stage_highlight_detection` | `backend/pipeline/stages.py:2444+` | Celery task that scores all KeyMoments for a video and upserts HighlightCandidates |
| `KeyMoment` model | `backend/models.py` | Has `title`, `summary`, `start_time`, `end_time`, `content_type`, `plugins`, `raw_transcript`, `chapter_status`, `sort_order` |
### What Exists (Frontend)
| Component | Location | Relevance |
|-----------|----------|-----------|
| `ChapterReview.tsx` (525 lines) | `frontend/src/pages/ChapterReview.tsx` | **Primary pattern** — creator-scoped video review with WaveSurfer waveform, status badges, action buttons, sidebar nav |
| `ChapterReview.module.css` (335 lines) | Same dir | CSS module pattern for creator review pages |
| `SidebarNav` component | Exported from `CreatorDashboard.tsx` | Reusable sidebar with Dashboard/Chapters/Consent/Settings links — needs "Highlights" link added |
| `CreatorDashboard.module.css` | Same dir | Shared layout/sidebar styles |
| `api/videos.ts` | `frontend/src/api/videos.ts` | Pattern for chapter API calls — `fetchCreatorChapters`, `updateChapter`, `approveChapters` |
| `api/client.ts` | `frontend/src/api/client.ts` | `request<T>()` helper with auth token injection |
| `ProtectedRoute` | `frontend/src/components/ProtectedRoute.tsx` | Auth guard wrapper |
| `App.tsx` routes | `frontend/src/App.tsx:172-209` | Route registration pattern — lazy-loaded creator pages under `/creator/*` |
### What's Missing
1. **Creator-scoped highlight API endpoints** — existing endpoints are admin-only at `/admin/highlights/*`. Need new `/creator/highlights` endpoints that scope to the authenticated creator's videos.
2. **Status transition endpoints** — no PATCH/PUT endpoint to change highlight status (approve/reject/discard). The admin router only has GET (list/detail) and POST (trigger detection).
3. **"Shorts" concept** — no model, no column, no flag anywhere. The roadmap mentions "short candidates" — this is a UI filter/tag on highlights where `duration_secs` falls within a shorts-eligible range (≤60s based on the scorer's sweet spot curve peaking at 30-60s).
4. **Trim action** — the roadmap says "approve, trim, discard." Trim implies adjusting `start_time`/`end_time` on the underlying KeyMoment or storing a trim window on the HighlightCandidate. The HighlightCandidate model has no trim columns. Options:
- Add `trim_start` / `trim_end` nullable Float columns to `highlight_candidates` (preferred — non-destructive)
- Or modify the KeyMoment times directly (destructive, bad idea)
5. **Frontend page** — no `HighlightQueue.tsx` or similar page exists.
6. **Sidebar nav link**`SidebarNav` needs a "Highlights" entry.
### Key Constraints
- **Auth scoping:** Creator endpoints use the JWT auth from `backend/routers/auth.py` (D036). The `creator_chapters.py` router shows the pattern: `current_user = Depends(get_current_user)`, then filter by `creator_id = current_user.creator_id`.
- **HighlightStatus enum:** Currently `candidate/approved/rejected`. Adding `discarded` requires an Alembic migration to alter the PostgreSQL enum. Alternatively, "discard" could map to "rejected" to avoid migration complexity — the UI just labels it differently.
- **No waveform for highlights:** ChapterReview uses WaveSurfer with actual audio files. Highlights don't necessarily have audio access. The UI should show a visual timeline/score breakdown instead of a waveform, unless the video's audio URL is available (via `SourceVideo.file_path`).
## Natural Seams (Task Decomposition)
### Seam 1: Backend API (creator-scoped highlights + status actions)
- New router: `backend/routers/creator_highlights.py`
- Endpoints: `GET /creator/highlights` (list, filtered by creator), `GET /creator/highlights/{id}` (detail with KeyMoment info), `PATCH /creator/highlights/{id}` (status change: approve/reject/discard), `PATCH /creator/highlights/{id}/trim` (set trim_start/trim_end)
- Alembic migration: add `trim_start`, `trim_end` columns to `highlight_candidates`
- Register router in `backend/main.py`
- Pattern: follow `backend/routers/creator_chapters.py` exactly
### Seam 2: Frontend API layer + types
- New file: `frontend/src/api/highlights.ts`
- Types: `HighlightCandidate`, `ScoreBreakdown`, list response
- Functions: `fetchCreatorHighlights()`, `updateHighlightStatus()`, `trimHighlight()`
- Pattern: follow `frontend/src/api/videos.ts`
### Seam 3: Frontend page — HighlightQueue.tsx
- New file: `frontend/src/pages/HighlightQueue.tsx` + `.module.css`
- Layout: `SidebarNav` + content area (same as ChapterReview)
- Features:
- Filter tabs: All / Shorts (≤60s) / Approved / Rejected
- Candidate cards: title, duration, score bar, content_type badge, status badge
- Score breakdown visualization (7 dimensions as small bar chart or radar)
- Action buttons: Approve (green), Discard (red), Trim (opens inline trim UI)
- Trim UI: two number inputs or range slider for start/end adjustment
- Pattern: follow ChapterReview layout + CreatorDashboard sidebar
### Seam 4: Wiring (routes, sidebar, lazy loading)
- Add route to `App.tsx`: `/creator/highlights` and `/creator/highlights/:videoId`
- Add "Highlights" link to `SidebarNav` in `CreatorDashboard.tsx`
- Lazy-load import
### Build Order
1. **Backend first** (Seam 1) — unblocks everything, can be tested with curl
2. **API layer** (Seam 2) — thin, quick
3. **Page + CSS** (Seam 3) — bulk of the work
4. **Wiring** (Seam 4) — can fold into Seam 3
### Verification
- `docker compose build` succeeds
- `cd frontend && npm run build` — zero TypeScript errors
- `curl` to creator highlight endpoints returns scoped data
- Visual check: navigate to `/creator/highlights`, see candidates listed with scores, filter by shorts, approve/discard/trim actions work
## Risks
| Risk | Mitigation |
|------|-----------|
| No highlight data exists in DB yet (detection may not have been run) | Backend detect-all endpoint exists; include a "Run Detection" button in the UI or document that admin must trigger first |
| Trim columns need Alembic migration | Simple nullable Float columns, no complex migration |
| HighlightStatus enum expansion for "discarded" | Map discard → rejected to avoid enum migration; or add Alembic ALTER TYPE if distinct status needed |
## Forward Intelligence for Planner
- **ChapterReview.tsx is THE pattern** — 525 lines, waveform + sidebar + status transitions + badges. Copy its structure for HighlightQueue.
- **SidebarNav is exported** from `CreatorDashboard.tsx` (line ~12 export) — just add a link.
- **CSS custom properties** already include `--color-btn-approve`, `--color-btn-reject`, `--color-badge-approved-*`, `--color-badge-rejected-*` — reuse them.
- **Creator auth pattern** is in `creator_chapters.py``get_current_user` dependency → `current_user.creator_id` filter.
- **The "shorts" concept is purely a UI filter** — any highlight with `duration_secs ≤ 60` is a "short candidate." No new model needed.
- **Score breakdown JSONB** already has 7 named dimensions — render as horizontal bars or a small radar chart.
- **Alembic migration numbering:** latest is `020_*`. Next migration is `021_*`.

View file

@ -0,0 +1,78 @@
---
estimated_steps: 39
estimated_files: 4
skills_used: []
---
# T01: Creator-scoped highlights API + Alembic migration
Build the backend foundation: a new FastAPI router with creator-scoped highlight endpoints, an Alembic migration for trim columns, and model updates. Follows the `creator_chapters.py` auth/ownership pattern exactly.
## Steps
1. Add `trim_start` (nullable Float) and `trim_end` (nullable Float) columns to `HighlightCandidate` in `backend/models.py` (after `status` field, before `created_at`)
2. Create Alembic migration `alembic/versions/021_add_highlight_trim_columns.py` that adds `trim_start` and `trim_end` nullable float columns to `highlight_candidates` table
3. Create `backend/routers/creator_highlights.py` with these endpoints:
- `GET /creator/highlights` — list highlights for creator's videos, with optional `?status=` and `?shorts_only=true` (duration_secs ≤ 60) query filters. Eager-load `key_moment` relationship for title/start_time/end_time. Order by score DESC.
- `GET /creator/highlights/{highlight_id}` — single highlight detail with full score_breakdown and key_moment info. Verify creator owns the source video.
- `PATCH /creator/highlights/{highlight_id}` — update status (approve/reject). Accept `{"status": "approved"|"rejected"}` body. Verify creator ownership. Map UI "discard" to `rejected` status to avoid enum migration.
- `PATCH /creator/highlights/{highlight_id}/trim` — set trim_start/trim_end. Accept `{"trim_start": float, "trim_end": float}` body. Validate: both non-negative, trim_start < trim_end. Verify creator ownership.
4. Use Pydantic schemas for request/response: create `HighlightListResponse`, `HighlightDetailResponse`, `HighlightStatusUpdate`, `HighlightTrimUpdate` in the router file (or a separate schemas file). Reuse `HighlightScoreBreakdown` from `backend/pipeline/highlight_schemas.py`.
5. Auth pattern: `current_user: User = Depends(get_current_user)`, then filter `SourceVideo.creator_id == current_user.creator_id`. Reuse `_verify_creator_owns_video` helper or create equivalent.
6. Register the router in `backend/main.py`: add `creator_highlights` to the import line and `app.include_router(creator_highlights.router, prefix="/api/v1")`
7. Add `chrysopedia.creator_highlights` logger with info-level messages on status changes and trim actions.
## Failure Modes
| Dependency | On error | On timeout | On malformed response |
|------------|----------|-----------|----------------------|
| PostgreSQL | Return 500 with logged traceback | SQLAlchemy handles connection pool timeout | N/A (ORM) |
| Auth (JWT) | Return 401 via get_current_user | N/A | Return 401 |
## Negative Tests
- **Malformed inputs**: PATCH with invalid status string → 422; trim with negative values or trim_start >= trim_end → 400
- **Error paths**: No creator profile → 403; highlight not found → 404; highlight belongs to different creator → 404
- **Boundary conditions**: Empty highlight list returns `[]`; shorts_only with no qualifying highlights returns `[]`
## Must-Haves
- [ ] `trim_start` and `trim_end` nullable Float columns on HighlightCandidate model
- [ ] Alembic migration 021 applies cleanly
- [ ] GET /creator/highlights returns creator-scoped results with status/shorts filters
- [ ] GET /creator/highlights/{id} returns detail with score_breakdown and key_moment
- [ ] PATCH /creator/highlights/{id} changes status with ownership verification
- [ ] PATCH /creator/highlights/{id}/trim sets trim window with validation
- [ ] Router registered in main.py
## Verification
- `cd /home/aux/projects/content-to-kb-automator && python -c "from routers.creator_highlights import router; print('Router imported OK')"` (run from backend dir)
- `cd /home/aux/projects/content-to-kb-automator && python -c "from models import HighlightCandidate; assert hasattr(HighlightCandidate, 'trim_start'); assert hasattr(HighlightCandidate, 'trim_end'); print('Model columns OK')"`
- `grep -q 'creator_highlights' backend/main.py && echo 'Router registered'`
- `python -c "import ast; ast.parse(open('alembic/versions/021_add_highlight_trim_columns.py').read()); print('Migration syntax OK')"`
## Observability Impact
- Signals added: `chrysopedia.creator_highlights` logger with info-level status transition and trim action messages
- Inspection: `GET /api/v1/creator/highlights?status=approved` to check approved highlights; DB query on `highlight_candidates` table
- Failure state: 403/404 errors with specific detail messages logged at warning level
## Inputs
- ``backend/models.py` — HighlightCandidate model (lines 706-741) to add trim columns`
- ``backend/routers/creator_chapters.py` — auth/ownership pattern to replicate`
- ``backend/pipeline/highlight_schemas.py` — HighlightScoreBreakdown schema to reuse`
- ``backend/main.py` — router registration pattern (line 15, 78-96)`
- ``alembic/versions/020_add_chapter_status_and_sort_order.py` — latest migration for revision chain`
## Expected Output
- ``backend/models.py` — HighlightCandidate with trim_start and trim_end columns`
- ``alembic/versions/021_add_highlight_trim_columns.py` — migration adding trim columns`
- ``backend/routers/creator_highlights.py` — new router with 4 endpoints`
- ``backend/main.py` — updated with creator_highlights import and include_router`
## Verification
cd /home/aux/projects/content-to-kb-automator && python -c "import sys; sys.path.insert(0,'backend'); from models import HighlightCandidate; assert hasattr(HighlightCandidate,'trim_start'); print('OK')" && grep -q creator_highlights backend/main.py && echo 'Registered'

View file

@ -0,0 +1,83 @@
---
id: T01
parent: S01
milestone: M022
provides: []
requires: []
affects: []
key_files: ["backend/models.py", "alembic/versions/021_add_highlight_trim_columns.py", "backend/routers/creator_highlights.py", "backend/main.py"]
key_decisions: ["Pydantic schemas defined inline in the router file rather than in a separate schemas.py — keeps the creator_highlights module self-contained"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "All four verification checks pass: router imports OK, model columns present, router registered in main.py, migration syntax valid."
completed_at: 2026-04-04T06:58:23.753Z
blocker_discovered: false
---
# T01: Add creator-scoped highlight review endpoints (list/detail/status/trim) with Alembic migration for trim columns
> Add creator-scoped highlight review endpoints (list/detail/status/trim) with Alembic migration for trim columns
## What Happened
---
id: T01
parent: S01
milestone: M022
key_files:
- backend/models.py
- alembic/versions/021_add_highlight_trim_columns.py
- backend/routers/creator_highlights.py
- backend/main.py
key_decisions:
- Pydantic schemas defined inline in the router file rather than in a separate schemas.py — keeps the creator_highlights module self-contained
duration: ""
verification_result: passed
completed_at: 2026-04-04T06:58:23.754Z
blocker_discovered: false
---
# T01: Add creator-scoped highlight review endpoints (list/detail/status/trim) with Alembic migration for trim columns
**Add creator-scoped highlight review endpoints (list/detail/status/trim) with Alembic migration for trim columns**
## What Happened
Built the backend foundation for the highlight review queue. Added trim_start and trim_end nullable Float columns to HighlightCandidate model. Created Alembic migration 021 chained from 020. Created creator_highlights router with four endpoints: list (with status/shorts filters), detail (with score_breakdown), status update (approve/reject), and trim update (with validation). Registered router in main.py. Auth pattern follows creator_chapters.py exactly. Added chrysopedia.creator_highlights logger.
## Verification
All four verification checks pass: router imports OK, model columns present, router registered in main.py, migration syntax valid.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `python -c "from routers.creator_highlights import router; print('Router imported OK')"` | 0 | ✅ pass | 500ms |
| 2 | `python -c "from models import HighlightCandidate; assert hasattr(HighlightCandidate, 'trim_start'); assert hasattr(HighlightCandidate, 'trim_end'); print('Model columns OK')"` | 0 | ✅ pass | 500ms |
| 3 | `grep -q 'creator_highlights' backend/main.py && echo 'Router registered'` | 0 | ✅ pass | 100ms |
| 4 | `python -c "import ast; ast.parse(open('alembic/versions/021_add_highlight_trim_columns.py').read()); print('Migration syntax OK')"` | 0 | ✅ pass | 100ms |
## Deviations
None.
## Known Issues
None.
## Files Created/Modified
- `backend/models.py`
- `alembic/versions/021_add_highlight_trim_columns.py`
- `backend/routers/creator_highlights.py`
- `backend/main.py`
## Deviations
None.
## Known Issues
None.

View file

@ -0,0 +1,91 @@
---
estimated_steps: 51
estimated_files: 5
skills_used: []
---
# T02: HighlightQueue page, API layer, route wiring, and sidebar link
Build the complete frontend: TypeScript API layer, HighlightQueue page with filter tabs and action controls, CSS module, route registration in App.tsx, and Highlights link in SidebarNav. Follows the ChapterReview.tsx pattern for layout and CreatorDashboard SidebarNav for navigation.
## Steps
1. Create `frontend/src/api/highlights.ts` with:
- TypeScript interfaces: `HighlightCandidate` (id, key_moment_id, source_video_id, score, score_breakdown: ScoreBreakdown, duration_secs, status, trim_start, trim_end, created_at, key_moment: { title, start_time, end_time, content_type }), `ScoreBreakdown` (7 float fields matching backend), list response type
- API functions: `fetchCreatorHighlights(params?: { status?: string; shorts_only?: boolean })`, `fetchHighlightDetail(id: string)`, `updateHighlightStatus(id: string, status: string)`, `trimHighlight(id: string, trim_start: number, trim_end: number)`
- Use `request<T>()` and `BASE` from `./client`
2. Create `frontend/src/pages/HighlightQueue.module.css` following ChapterReview.module.css patterns:
- Layout: `.pageLayout` (sidebar + content grid), `.content`, `.header`
- Filter tabs: `.filterTabs`, `.filterTab`, `.filterTabActive`
- Cards: `.candidateCard`, `.candidateScore`, `.candidateTitle`, `.candidateDuration`, `.candidateStatus`
- Score breakdown bars: `.scoreBar`, `.scoreBarFill`, `.scoreLabel`
- Action buttons: `.actionBtn`, `.approveBtn`, `.rejectBtn`, `.trimBtn` — reuse CSS custom properties `--color-btn-approve`, `--color-btn-reject`
- Status badges: reuse `--color-badge-approved-*`, `--color-badge-rejected-*` custom property patterns
- Trim UI: `.trimPanel`, `.trimInput`
- Empty state: `.emptyState`
- Shorts badge: `.shortsBadge` (small pill for ≤60s candidates)
3. Create `frontend/src/pages/HighlightQueue.tsx`:
- Import `SidebarNav` from `./CreatorDashboard`
- State: highlights array, loading boolean, active filter tab (all/shorts/approved/rejected), optional expanded highlight ID for trim UI
- On mount: fetch highlights from API, apply filter based on active tab
- Filter tabs: All | Shorts (≤60s) | Approved | Rejected — clicking a tab re-fetches with appropriate query params
- Candidate cards showing: key_moment title, formatted duration, composite score as percentage bar, content_type badge, status badge
- Score breakdown section: 7 horizontal bars (one per dimension) with labels and percentages
- Action buttons per card: Approve (green, calls updateHighlightStatus), Discard (red, calls updateHighlightStatus with 'rejected'), Trim (toggles inline trim panel)
- Trim panel: two number inputs (start seconds, end seconds) with Save/Cancel buttons. Pre-fill with current key_moment start_time/end_time if no trim exists. On save, call trimHighlight API.
- Empty state message when no highlights match current filter
- Loading spinner during fetch
- Error handling: catch ApiError, show user-friendly message
4. Add lazy import and route in `frontend/src/App.tsx`:
- Add `const HighlightQueue = React.lazy(() => import("./pages/HighlightQueue"));` with other lazy imports
- Add route: `<Route path="/creator/highlights" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><HighlightQueue /></Suspense></ProtectedRoute>} />`
- Place after the existing `/creator/chapters/:videoId` route
5. Add "Highlights" link to `SidebarNav` in `frontend/src/pages/CreatorDashboard.tsx`:
- Add a new NavLink entry with a star/sparkle SVG icon and text "Highlights" linking to `/creator/highlights`
- Place after the "Chapters" link in the nav list
## Must-Haves
- [ ] TypeScript API layer with all 4 functions matching backend endpoints
- [ ] HighlightQueue page renders with SidebarNav, filter tabs, candidate cards, score bars, and action buttons
- [ ] Filter tabs switch between All / Shorts / Approved / Rejected views
- [ ] Approve and Discard buttons call the status update API and refresh the list
- [ ] Trim panel opens inline with number inputs, validates, and calls trim API
- [ ] Route registered in App.tsx with ProtectedRoute and Suspense
- [ ] SidebarNav has "Highlights" link
- [ ] `npx tsc --noEmit` passes with zero errors
## Verification
- `cd /home/aux/projects/content-to-kb-automator/frontend && npx tsc --noEmit` — zero errors
- `test -f frontend/src/api/highlights.ts && echo 'API layer exists'`
- `test -f frontend/src/pages/HighlightQueue.tsx && echo 'Page exists'`
- `test -f frontend/src/pages/HighlightQueue.module.css && echo 'CSS exists'`
- `grep -q 'HighlightQueue' frontend/src/App.tsx && echo 'Route wired'`
- `grep -q 'Highlights' frontend/src/pages/CreatorDashboard.tsx && echo 'Sidebar link added'`
## Inputs
- ``backend/routers/creator_highlights.py` — API contract (endpoint paths, request/response shapes) from T01`
- ``frontend/src/api/client.ts` — request helper and BASE constant`
- ``frontend/src/api/videos.ts` — API function pattern to follow`
- ``frontend/src/pages/ChapterReview.tsx` — page layout pattern (SidebarNav + content, status badges, action buttons)`
- ``frontend/src/pages/ChapterReview.module.css` — CSS module pattern for creator review pages`
- ``frontend/src/pages/CreatorDashboard.tsx` — SidebarNav component to add Highlights link (lines 12-52)`
- ``frontend/src/App.tsx` — lazy import and route registration pattern (lines 13-25, 201-205)`
## Expected Output
- ``frontend/src/api/highlights.ts` — TypeScript API layer with 4 functions and interfaces`
- ``frontend/src/pages/HighlightQueue.tsx` — highlight queue page component`
- ``frontend/src/pages/HighlightQueue.module.css` — CSS module for the page`
- ``frontend/src/App.tsx` — updated with HighlightQueue lazy import and route`
- ``frontend/src/pages/CreatorDashboard.tsx` — updated SidebarNav with Highlights link`
## Verification
cd /home/aux/projects/content-to-kb-automator/frontend && npx tsc --noEmit && grep -q HighlightQueue src/App.tsx && grep -q Highlights src/pages/CreatorDashboard.tsx && echo 'All checks pass'

File diff suppressed because one or more lines are too long

View file

@ -130,7 +130,7 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
</div>
<div class="hdr-right">
<span class="gen-lbl">Updated</span>
<span class="gen">Apr 3, 2026, 11:30 PM</span>
<span class="gen">Apr 4, 2026, 06:50 AM</span>
</div>
</div>
</header>
@ -156,6 +156,10 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
<div class="toc-group-label">M019</div>
<ul><li><a href="M019-2026-04-03T23-30-16.html">Apr 3, 2026, 11:30 PM</a> <span class="toc-kind toc-milestone">milestone</span></li></ul>
</div>
<div class="toc-group">
<div class="toc-group-label">M021</div>
<ul><li><a href="M021-2026-04-04T06-50-37.html">Apr 4, 2026, 06:50 AM</a> <span class="toc-kind toc-milestone">milestone</span></li></ul>
</div>
</aside>
<!-- Main content -->
@ -164,41 +168,43 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
<h2>Project Overview</h2>
<div class="idx-summary">
<div class="idx-stat"><span class="idx-val">$411.26</span><span class="idx-lbl">Total Cost</span></div>
<div class="idx-stat"><span class="idx-val">584.83M</span><span class="idx-lbl">Total Tokens</span></div>
<div class="idx-stat"><span class="idx-val">17h 48m</span><span class="idx-lbl">Duration</span></div>
<div class="idx-stat"><span class="idx-val">79/123</span><span class="idx-lbl">Slices</span></div>
<div class="idx-stat"><span class="idx-val">19/25</span><span class="idx-lbl">Milestones</span></div>
<div class="idx-stat"><span class="idx-val">4</span><span class="idx-lbl">Reports</span></div>
<div class="idx-stat"><span class="idx-val">$485.08</span><span class="idx-lbl">Total Cost</span></div>
<div class="idx-stat"><span class="idx-val">685.34M</span><span class="idx-lbl">Total Tokens</span></div>
<div class="idx-stat"><span class="idx-val">21h 0m</span><span class="idx-lbl">Duration</span></div>
<div class="idx-stat"><span class="idx-val">94/123</span><span class="idx-lbl">Slices</span></div>
<div class="idx-stat"><span class="idx-val">21/25</span><span class="idx-lbl">Milestones</span></div>
<div class="idx-stat"><span class="idx-val">5</span><span class="idx-lbl">Reports</span></div>
</div>
<div class="idx-progress">
<div class="idx-bar-track"><div class="idx-bar-fill" style="width:64%"></div></div>
<span class="idx-pct">64% complete</span>
<div class="idx-bar-track"><div class="idx-bar-fill" style="width:76%"></div></div>
<span class="idx-pct">76% complete</span>
</div>
<div class="sparkline-wrap"><h3>Cost Progression</h3>
<div class="sparkline">
<svg viewBox="0 0 600 60" width="600" height="60" class="spark-svg">
<polyline points="12.0,32.9 204.0,32.2 396.0,16.0 588.0,12.0" class="spark-line" fill="none"/>
<circle cx="12.0" cy="32.9" r="3" class="spark-dot">
<polyline points="12.0,35.2 156.0,34.6 300.0,20.9 444.0,17.5 588.0,12.0" class="spark-line" fill="none"/>
<circle cx="12.0" cy="35.2" r="3" class="spark-dot">
<title>M008: M008: Credibility Debt Cleanup — Broken Links, Test Data, Jargon, Empty Metrics — $172.23</title>
</circle><circle cx="204.0" cy="32.2" r="3" class="spark-dot">
</circle><circle cx="156.0" cy="34.6" r="3" class="spark-dot">
<title>M009: Homepage &amp; First Impression — $180.97</title>
</circle><circle cx="396.0" cy="16.0" r="3" class="spark-dot">
</circle><circle cx="300.0" cy="20.9" r="3" class="spark-dot">
<title>M018: M018: Phase 2 Research &amp; Documentation — Site Audit and Forgejo Wiki Bootstrap — $365.18</title>
</circle><circle cx="588.0" cy="12.0" r="3" class="spark-dot">
</circle><circle cx="444.0" cy="17.5" r="3" class="spark-dot">
<title>M019: Foundations — Auth, Consent &amp; LightRAG — $411.26</title>
</circle><circle cx="588.0" cy="12.0" r="3" class="spark-dot">
<title>M021: Intelligence Online — Chat, Chapters &amp; Search Cutover — $485.08</title>
</circle>
<text x="12" y="58" class="spark-lbl">$172.23</text>
<text x="588" y="58" text-anchor="end" class="spark-lbl">$411.26</text>
<text x="588" y="58" text-anchor="end" class="spark-lbl">$485.08</text>
</svg>
<div class="spark-axis">
<span class="spark-tick" style="left:2.0%" title="2026-03-31T05:31:26.249Z">M008</span><span class="spark-tick" style="left:34.0%" title="2026-03-31T05:52:28.456Z">M009</span><span class="spark-tick" style="left:66.0%" title="2026-04-03T21:17:51.201Z">M018</span><span class="spark-tick" style="left:98.0%" title="2026-04-03T23:30:16.641Z">M019</span>
<span class="spark-tick" style="left:2.0%" title="2026-03-31T05:31:26.249Z">M008</span><span class="spark-tick" style="left:26.0%" title="2026-03-31T05:52:28.456Z">M009</span><span class="spark-tick" style="left:50.0%" title="2026-04-03T21:17:51.201Z">M018</span><span class="spark-tick" style="left:74.0%" title="2026-04-03T23:30:16.641Z">M019</span><span class="spark-tick" style="left:98.0%" title="2026-04-04T06:50:37.759Z">M021</span>
</div>
</div></div>
</section>
<section class="idx-cards">
<h2>Progression <span class="sec-count">4</span></h2>
<h2>Progression <span class="sec-count">5</span></h2>
<div class="cards-grid">
<a class="report-card" href="M008-2026-03-31T05-31-26.html">
<div class="card-top">
@ -263,7 +269,7 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
<div class="card-delta"><span>+$184.21</span><span>+38 slices</span><span>+9 milestones</span></div>
</a>
<a class="report-card card-latest" href="M019-2026-04-03T23-30-16.html">
<a class="report-card" href="M019-2026-04-03T23-30-16.html">
<div class="card-top">
<span class="card-label">M019: Foundations — Auth, Consent &amp; LightRAG</span>
<span class="card-kind card-kind-milestone">milestone</span>
@ -282,6 +288,27 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
<span>79/123 slices</span>
</div>
<div class="card-delta"><span>+$46.09</span><span>+6 slices</span><span>+1 milestone</span></div>
</a>
<a class="report-card card-latest" href="M021-2026-04-04T06-50-37.html">
<div class="card-top">
<span class="card-label">M021: Intelligence Online — Chat, Chapters &amp; Search Cutover</span>
<span class="card-kind card-kind-milestone">milestone</span>
</div>
<div class="card-date">Apr 4, 2026, 06:50 AM</div>
<div class="card-progress">
<div class="card-bar-track">
<div class="card-bar-fill" style="width:76%"></div>
</div>
<span class="card-pct">76%</span>
</div>
<div class="card-stats">
<span>$485.08</span>
<span>685.34M</span>
<span>21h 0m</span>
<span>94/123 slices</span>
</div>
<div class="card-delta"><span>+$73.82</span><span>+15 slices</span><span>+2 milestones</span></div>
<div class="card-latest-badge">Latest</div>
</a></div>
</section>
@ -296,7 +323,7 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
<span class="ftr-sep"></span>
<span>/home/aux/projects/content-to-kb-automator</span>
<span class="ftr-sep"></span>
<span>Updated Apr 3, 2026, 11:30 PM</span>
<span>Updated Apr 4, 2026, 06:50 AM</span>
</div>
</footer>
</body>

View file

@ -67,6 +67,22 @@
"doneMilestones": 19,
"totalMilestones": 25,
"phase": "planning"
},
{
"filename": "M021-2026-04-04T06-50-37.html",
"generatedAt": "2026-04-04T06:50:37.759Z",
"milestoneId": "M021",
"milestoneTitle": "Intelligence Online — Chat, Chapters & Search Cutover",
"label": "M021: Intelligence Online — Chat, Chapters & Search Cutover",
"kind": "milestone",
"totalCost": 485.0819890000003,
"totalTokens": 685341099,
"totalDuration": 75620579,
"doneSlices": 94,
"totalSlices": 123,
"doneMilestones": 21,
"totalMilestones": 25,
"phase": "planning"
}
]
}

View file

@ -0,0 +1,24 @@
"""Add trim_start and trim_end columns to highlight_candidates.
Revision ID: 021_add_highlight_trim_columns
Revises: 020_add_chapter_status_and_sort_order
"""
from alembic import op
import sqlalchemy as sa
revision = "021_add_highlight_trim_columns"
down_revision = "020_add_chapter_status_and_sort_order"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("highlight_candidates", sa.Column("trim_start", sa.Float(), nullable=True))
op.add_column("highlight_candidates", sa.Column("trim_end", sa.Float(), nullable=True))
def downgrade() -> None:
op.drop_column("highlight_candidates", "trim_end")
op.drop_column("highlight_candidates", "trim_start")

View file

@ -12,7 +12,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from config import get_settings
from routers import admin, auth, chat, consent, creator_chapters, creator_dashboard, creators, health, highlights, ingest, pipeline, reports, search, stats, techniques, topics, videos
from routers import admin, auth, chat, consent, creator_chapters, creator_dashboard, creator_highlights, creators, health, highlights, ingest, pipeline, reports, search, stats, techniques, topics, videos
def _setup_logging() -> None:
@ -84,6 +84,7 @@ app.include_router(chat.router, prefix="/api/v1")
app.include_router(consent.router, prefix="/api/v1")
app.include_router(creator_dashboard.router, prefix="/api/v1")
app.include_router(creator_chapters.router, prefix="/api/v1")
app.include_router(creator_highlights.router, prefix="/api/v1")
app.include_router(creators.router, prefix="/api/v1")
app.include_router(highlights.router, prefix="/api/v1")
app.include_router(ingest.router, prefix="/api/v1")

View file

@ -725,6 +725,8 @@ class HighlightCandidate(Base):
default=HighlightStatus.candidate,
server_default="candidate",
)
trim_start: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
trim_end: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(
default=_now, server_default=func.now()
)

View file

@ -0,0 +1,230 @@
"""Creator highlight management endpoints — review, trim, approve/reject highlights.
Auth-guarded endpoints for creators to manage auto-detected highlight
candidates for their videos.
"""
import logging
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from auth import get_current_user
from database import get_session
from models import HighlightCandidate, HighlightStatus, KeyMoment, SourceVideo, User
from pipeline.highlight_schemas import HighlightScoreBreakdown
logger = logging.getLogger("chrysopedia.creator_highlights")
router = APIRouter(prefix="/creator", tags=["creator-highlights"])
# ── Pydantic Schemas ─────────────────────────────────────────────────────────
class KeyMomentInfo(BaseModel):
"""Embedded key-moment info for highlight responses."""
id: uuid.UUID
title: str
start_time: float
end_time: float
model_config = {"from_attributes": True}
class HighlightListItem(BaseModel):
"""Single item in the highlights list response."""
id: uuid.UUID
key_moment_id: uuid.UUID
source_video_id: uuid.UUID
score: float
duration_secs: float
status: str
trim_start: float | None = None
trim_end: float | None = None
key_moment: KeyMomentInfo | None = None
model_config = {"from_attributes": True}
class HighlightListResponse(BaseModel):
"""Response for GET /creator/highlights."""
highlights: list[HighlightListItem]
class HighlightDetailResponse(BaseModel):
"""Response for GET /creator/highlights/{id} with full breakdown."""
id: uuid.UUID
key_moment_id: uuid.UUID
source_video_id: uuid.UUID
score: float
score_breakdown: HighlightScoreBreakdown | None = None
duration_secs: float
status: str
trim_start: float | None = None
trim_end: float | None = None
key_moment: KeyMomentInfo | None = None
model_config = {"from_attributes": True}
class HighlightStatusUpdate(BaseModel):
"""Request body for PATCH status."""
status: str = Field(..., pattern="^(approved|rejected)$", description="New status: approved or rejected")
class HighlightTrimUpdate(BaseModel):
"""Request body for PATCH trim."""
trim_start: float = Field(..., ge=0.0, description="Trim start in seconds")
trim_end: float = Field(..., ge=0.0, description="Trim end in seconds")
# ── Helpers ──────────────────────────────────────────────────────────────────
async def _verify_creator(current_user: User) -> uuid.UUID:
"""Return creator_id or raise 403."""
if current_user.creator_id is None:
raise HTTPException(status_code=403, detail="No creator profile linked")
return current_user.creator_id
async def _get_highlight_for_creator(
highlight_id: uuid.UUID,
creator_id: uuid.UUID,
db: AsyncSession,
*,
eager_key_moment: bool = False,
) -> HighlightCandidate:
"""Fetch highlight and verify creator ownership via source video."""
stmt = select(HighlightCandidate).where(HighlightCandidate.id == highlight_id)
if eager_key_moment:
stmt = stmt.options(selectinload(HighlightCandidate.key_moment))
highlight = (await db.execute(stmt)).scalar_one_or_none()
if highlight is None:
raise HTTPException(status_code=404, detail="Highlight not found")
# Verify ownership via source video
video = (await db.execute(
select(SourceVideo).where(
SourceVideo.id == highlight.source_video_id,
SourceVideo.creator_id == creator_id,
)
)).scalar_one_or_none()
if video is None:
raise HTTPException(status_code=404, detail="Highlight not found or not owned by you")
return highlight
# ── Endpoints ────────────────────────────────────────────────────────────────
@router.get("/highlights", response_model=HighlightListResponse)
async def list_creator_highlights(
current_user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_session),
status: str | None = Query(None, description="Filter by status: candidate, approved, rejected"),
shorts_only: bool = Query(False, description="Only show highlights ≤ 60s"),
) -> HighlightListResponse:
"""List highlights for the creator's videos with optional filters."""
creator_id = await _verify_creator(current_user)
# Get all video IDs for this creator
video_ids_stmt = select(SourceVideo.id).where(SourceVideo.creator_id == creator_id)
stmt = (
select(HighlightCandidate)
.where(HighlightCandidate.source_video_id.in_(video_ids_stmt))
.options(selectinload(HighlightCandidate.key_moment))
.order_by(HighlightCandidate.score.desc())
)
if status is not None:
try:
HighlightStatus(status)
except ValueError:
raise HTTPException(status_code=422, detail=f"Invalid status: {status}")
stmt = stmt.where(HighlightCandidate.status == status)
if shorts_only:
stmt = stmt.where(HighlightCandidate.duration_secs <= 60.0)
result = await db.execute(stmt)
highlights = result.scalars().all()
logger.debug("Creator %s highlights: %d results", creator_id, len(highlights))
return HighlightListResponse(
highlights=[HighlightListItem.model_validate(h) for h in highlights],
)
@router.get("/highlights/{highlight_id}", response_model=HighlightDetailResponse)
async def get_creator_highlight(
highlight_id: uuid.UUID,
current_user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_session),
) -> HighlightDetailResponse:
"""Get full detail for a single highlight including score breakdown."""
creator_id = await _verify_creator(current_user)
highlight = await _get_highlight_for_creator(
highlight_id, creator_id, db, eager_key_moment=True,
)
return HighlightDetailResponse.model_validate(highlight)
@router.patch("/highlights/{highlight_id}", response_model=HighlightDetailResponse)
async def update_highlight_status(
highlight_id: uuid.UUID,
body: HighlightStatusUpdate,
current_user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_session),
) -> HighlightDetailResponse:
"""Approve or reject a highlight. Maps UI 'discard' to 'rejected'."""
creator_id = await _verify_creator(current_user)
highlight = await _get_highlight_for_creator(
highlight_id, creator_id, db, eager_key_moment=True,
)
old_status = highlight.status
highlight.status = HighlightStatus(body.status)
await db.commit()
await db.refresh(highlight)
logger.info(
"Highlight %s status: %s%s (creator %s)",
highlight_id, old_status.value, body.status, creator_id,
)
return HighlightDetailResponse.model_validate(highlight)
@router.patch("/highlights/{highlight_id}/trim", response_model=HighlightDetailResponse)
async def update_highlight_trim(
highlight_id: uuid.UUID,
body: HighlightTrimUpdate,
current_user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_session),
) -> HighlightDetailResponse:
"""Set trim start/end for a highlight clip."""
creator_id = await _verify_creator(current_user)
# Validate trim_start < trim_end
if body.trim_start >= body.trim_end:
raise HTTPException(
status_code=400,
detail="trim_start must be less than trim_end",
)
highlight = await _get_highlight_for_creator(
highlight_id, creator_id, db, eager_key_moment=True,
)
highlight.trim_start = body.trim_start
highlight.trim_end = body.trim_end
await db.commit()
await db.refresh(highlight)
logger.info(
"Highlight %s trim: %.2f%.2f (creator %s)",
highlight_id, body.trim_start, body.trim_end, creator_id,
)
return HighlightDetailResponse.model_validate(highlight)