feat: Added GET /creator/transparency endpoint returning technique page…

- "backend/schemas.py"
- "backend/routers/creator_dashboard.py"

GSD-Task: S05/T01
This commit is contained in:
jlightner 2026-04-04 13:55:13 +00:00
parent be1919e223
commit b32fc5134b
12 changed files with 809 additions and 3 deletions

View file

@ -4,7 +4,7 @@
## Current State
Twenty-four milestones complete. M024 delivered the shorts publishing flow end-to-end (INT-2): shareable public URLs via token-based access, Whisper-generated ASS karaoke captions, creator-configurable intro/outro card templates, embeddable chrome-free player at /embed/:videoId, key moment color-coded pins on the player timeline with inline technique page player, and citation UX improvements with timestamp badge links and video metadata on source cards. 45 unit tests added for caption and card generation. Forgejo wiki at 20 pages. The system is deployed and running on ub01 at `http://ub01:8096`. Forgejo knowledgebase wiki live at `https://git.xpltd.co/xpltdco/chrysopedia/wiki/`.
Twenty-four milestones complete. M025 (Hardening & Launch Prep) in progress — S01-S04 done: email notification digests, mobile responsiveness pass, creator onboarding flow, and rate limiting + cost management. Chat requests are now rate-limited per-user/IP/creator via Redis sliding-window, with token usage logged to PostgreSQL and an admin dashboard at /admin/usage. Forgejo wiki at 20 pages. The system is deployed and running on ub01 at `http://ub01:8096`. Forgejo knowledgebase wiki live at `https://git.xpltd.co/xpltdco/chrysopedia/wiki/`.
### What's Built

View file

@ -9,7 +9,7 @@ Production hardening, mobile polish, creator onboarding, and formal validation.
| S01 | [A] Notification System (Email Digests) | medium | — | ✅ | Followers receive email digests when followed creators post new content |
| S02 | [A] Mobile Responsiveness Pass | medium | — | ✅ | All new Phase 2 UI surfaces pass visual check at 375px and 768px |
| S03 | [A] Creator Onboarding Flow | low | — | ✅ | New creator signs up, follows guided upload, sets consent, sees dashboard tour |
| S04 | [B] Rate Limiting + Cost Management | low | — | | Chat requests limited per-user and per-creator. Token usage dashboard in admin. |
| S04 | [B] Rate Limiting + Cost Management | low | — | | Chat requests limited per-user and per-creator. Token usage dashboard in admin. |
| S05 | [B] AI Transparency Page | low | — | ⬜ | Creator sees all entities, relationships, and technique pages derived from their content |
| S06 | [B] Graph Backend Evaluation | low | — | ⬜ | Benchmark report: NetworkX vs Neo4j at current and projected entity counts |
| S07 | [A] Data Export (GDPR-Style) | medium | — | ⬜ | Creator downloads a ZIP with all derived content, entities, and relationships |

View file

@ -0,0 +1,118 @@
---
id: S04
parent: M025
milestone: M025
provides:
- Redis sliding-window rate limiter class (backend/rate_limiter.py)
- ChatUsageLog model for per-request token tracking
- GET /api/v1/admin/usage endpoint with period stats, top creators/users, daily counts
- AdminUsage page at /admin/usage
requires:
[]
affects:
- S11
key_files:
- backend/rate_limiter.py
- backend/models.py
- backend/routers/chat.py
- backend/chat_service.py
- backend/config.py
- alembic/versions/031_add_chat_usage_log.py
- backend/routers/admin.py
- frontend/src/pages/AdminUsage.tsx
- frontend/src/pages/AdminUsage.module.css
- frontend/src/api/admin-usage.ts
key_decisions:
- Fail-open on Redis errors for rate limiter — availability over strictness
- Non-blocking usage log INSERT — errors logged, never block response
- stream_options usage capture with chars/4 fallback for non-supporting providers
- Group-by with labeled column reference for date_trunc to avoid PostgreSQL grouping errors
patterns_established:
- Redis sliding-window rate limiting with sorted sets (ZADD/ZREMRANGEBYSCORE/ZCARD)
- Non-blocking side-effect logging pattern — INSERT in try/except after SSE stream completes
- Admin-only analytics endpoint pattern with _require_admin guard and period aggregation
observability_surfaces:
- chat_usage_log table — SELECT count(*), sum(total_tokens) FROM chat_usage_log WHERE created_at > now() - interval '1 hour'
- Redis rate limit keys — redis-cli ZCARD chrysopedia:ratelimit:ip:<ip>
- Rate limit rejections logged at WARNING level with user/IP/creator context
- DB insert failures logged at ERROR with full context
- GET /api/v1/admin/usage — admin dashboard with today/week/month aggregation
drill_down_paths:
- .gsd/milestones/M025/slices/S04/tasks/T01-SUMMARY.md
- .gsd/milestones/M025/slices/S04/tasks/T02-SUMMARY.md
duration: ""
verification_result: passed
completed_at: 2026-04-04T13:46:40.126Z
blocker_discovered: false
---
# S04: [B] Rate Limiting + Cost Management
**Chat requests are rate-limited per-user/IP/creator via Redis sliding-window, token usage is logged to PostgreSQL, and an admin dashboard at /admin/usage shows aggregated cost data.**
## What Happened
This slice added two capabilities: request rate limiting and token usage tracking with an admin dashboard.
**T01 — Rate Limiter + Usage Logging:** Built `RateLimiter` class in `backend/rate_limiter.py` using Redis sorted sets (ZADD timestamp, ZREMRANGEBYSCORE prune, ZCARD count) for a sliding-window algorithm. Three tiers checked before each chat request: per-user (30/hr, authenticated only), per-IP (10/hr, anonymous fallback), and per-creator (60/hr). All thresholds configurable via Settings env vars. Rate limiter fails open on Redis errors — logs WARNING, allows request through. Chat endpoint returns 429 JSON with `retry_after` header when any limit is exceeded.
Added `ChatUsageLog` model with fields for user_id, client_ip, creator_slug, query text, prompt/completion/total tokens, cascade_tier, model name, latency_ms, and created_at (indexed). Alembic migration 031 creates the table. ChatService captures real token counts via `stream_options={"include_usage": True}` on OpenAI calls, with chars/4 fallback for providers that don't support it. Usage log INSERT is non-blocking — errors logged but never block the SSE response.
**T02 — Admin Usage Dashboard:** Added `GET /api/v1/admin/usage` endpoint behind `_require_admin` guard. Returns today/week/month token totals and request counts, top 10 creators by total tokens, top 10 users by request count, and daily request counts for the last 7 days. Built AdminUsage React page with summary cards, CSS bar chart for daily activity, and breakdown tables. Wired into App.tsx routes and AdminDropdown menu.
## Verification
All slice-level verification passed on ub01:
1. **Imports clean:** `python -c "from rate_limiter import RateLimiter; from models import ChatUsageLog; print('imports ok')"` → ok ✅
2. **Migration current:** `alembic upgrade head` reports no pending migrations ✅
3. **Usage data exists:** `SELECT count(*), sum(total_tokens) FROM chat_usage_log` → 10 rows, 20478 total tokens ✅
4. **Rate limit keys in Redis:** `redis-cli KEYS 'chrysopedia:ratelimit:*'` shows `chrysopedia:ratelimit:ip:127.0.0.1`
5. **Config settable:** Settings show rate_limit_user_per_hour=30, rate_limit_ip_per_hour=10, rate_limit_creator_per_hour=60 ✅
6. **Admin endpoint auth:** Unauthenticated request to `/api/v1/admin/usage` returns 401 ✅
7. **429 on rate limit exceeded:** T01 verified 11th anonymous request returns HTTP 429 with JSON retry_after field ✅
8. **Admin page renders:** Browser navigated to /admin/usage with summary cards, bar chart, and tables ✅
## Requirements Advanced
None.
## Requirements Validated
None.
## New Requirements Surfaced
None.
## Requirements Invalidated or Re-scoped
None.
## Deviations
Alembic migration 025 had a pre-existing enum conflict on ub01 requiring manual stamp and table creation. Not caused by this slice's changes. Admin user created via SQL INSERT since none existed in the test environment.
## Known Limitations
Usage dashboard has no date range picker — shows fixed today/week/month windows. No export/CSV download. Rate limit thresholds are per-instance env vars, not per-creator configurable via admin UI.
## Follow-ups
None.
## Files Created/Modified
- `backend/rate_limiter.py` — New — Redis sliding-window rate limiter class
- `backend/models.py` — Added ChatUsageLog model
- `backend/config.py` — Added rate_limit_user_per_hour, rate_limit_ip_per_hour, rate_limit_creator_per_hour settings
- `backend/routers/chat.py` — Added rate limit checks (3 tiers) before chat processing, 429 response
- `backend/chat_service.py` — Added stream_options usage capture, non-blocking usage log INSERT
- `alembic/versions/031_add_chat_usage_log.py` — New — migration creating chat_usage_log table
- `backend/routers/admin.py` — Added GET /admin/usage endpoint with period stats and breakdowns
- `frontend/src/api/admin-usage.ts` — New — fetchUsageStats API client
- `frontend/src/pages/AdminUsage.tsx` — New — admin usage dashboard page
- `frontend/src/pages/AdminUsage.module.css` — New — usage dashboard styles
- `frontend/src/App.tsx` — Added lazy route for AdminUsage
- `frontend/src/components/AdminDropdown.tsx` — Added Usage link
- `frontend/src/api/index.ts` — Re-exported admin-usage module

View file

@ -0,0 +1,105 @@
# S04: [B] Rate Limiting + Cost Management — UAT
**Milestone:** M025
**Written:** 2026-04-04T13:46:40.126Z
## UAT: S04 — Rate Limiting + Cost Management
### Preconditions
- Chrysopedia running on ub01:8096 with all services healthy
- Redis and PostgreSQL containers up
- Admin user exists with valid JWT token
- At least one chat request has been made (to populate usage log)
### Test 1: Rate Limit — Anonymous IP Enforcement
1. Clear any existing rate limit keys: `docker exec chrysopedia-redis redis-cli DEL chrysopedia:ratelimit:ip:127.0.0.1`
2. Send 10 chat requests rapidly from same IP (no auth header):
```bash
for i in $(seq 1 10); do
curl -s -o /dev/null -w "%{http_code} " -X POST http://ub01:8096/api/v1/chat \
-H 'Content-Type: application/json' \
-d '{"query":"test","creator_slug":"test"}'
done
```
3. **Expected:** First 10 return 200 (or streaming response)
4. Send 11th request
5. **Expected:** HTTP 429 with JSON body containing `error` message and `retry_after` integer
### Test 2: Rate Limit — 429 Response Format
1. After hitting rate limit in Test 1, inspect the 429 response body:
```bash
curl -s -X POST http://ub01:8096/api/v1/chat \
-H 'Content-Type: application/json' \
-d '{"query":"test","creator_slug":"test"}'
```
2. **Expected:** JSON with keys `error` (string) and `retry_after` (integer, seconds until window resets)
### Test 3: Rate Limit — Fail-Open on Redis Down
1. Stop Redis: `docker stop chrysopedia-redis`
2. Send a chat request
3. **Expected:** Request proceeds normally (200/SSE stream). WARNING log in API container about Redis connection failure.
4. Restart Redis: `docker start chrysopedia-redis`
### Test 4: Usage Logging — Token Capture
1. Clear rate limits, send a chat request with a real query
2. Query the database:
```sql
SELECT client_ip, creator_slug, prompt_tokens, completion_tokens, total_tokens, cascade_tier, model, latency_ms
FROM chat_usage_log ORDER BY created_at DESC LIMIT 1;
```
3. **Expected:** Row exists with prompt_tokens > 0, completion_tokens > 0, total_tokens = prompt + completion, cascade_tier populated, model name populated, latency_ms > 0
### Test 5: Usage Logging — Non-Blocking on DB Error
1. Temporarily make chat_usage_log INSERT fail (e.g., revoke INSERT privilege)
2. Send a chat request
3. **Expected:** Chat response streams normally. ERROR log in API container about failed usage log INSERT. No user-visible error.
4. Restore INSERT privilege
### Test 6: Admin Usage Endpoint — Auth Guard
1. Request without auth: `curl -s -o /dev/null -w "%{http_code}" http://ub01:8096/api/v1/admin/usage`
2. **Expected:** 401
3. Request with non-admin token
4. **Expected:** 403
5. Request with admin token
6. **Expected:** 200 with JSON containing keys: `today`, `this_week`, `this_month`, `top_creators`, `top_users`, `daily_counts`
### Test 7: Admin Usage Endpoint — Data Shape
1. `curl -s -H 'Authorization: Bearer <admin_token>' http://ub01:8096/api/v1/admin/usage | python3 -m json.tool`
2. **Expected:**
- `today`, `this_week`, `this_month` each have `total_tokens` (int) and `request_count` (int)
- `top_creators` is array of objects with `creator_slug`, `request_count`, `total_tokens`
- `top_users` is array of objects with `display_name` (or username/IP), `request_count`, `total_tokens`
- `daily_counts` is array of objects with `date` (string) and `count` (int), 7 entries max
### Test 8: Admin Usage Page — UI Renders
1. Log in as admin, navigate to http://ub01:8096/admin/usage
2. **Expected:** Page loads with:
- Three summary cards (Today, This Week, This Month) showing token totals and request counts
- Bar chart showing daily request counts for last 7 days
- Per-creator breakdown table (top 10)
- Per-user breakdown table (top 10)
3. Loading and error states should be handled (show spinner during load, error message on failure)
### Test 9: Admin Dropdown — Usage Link
1. On any page, hover over Admin dropdown in nav
2. **Expected:** "Usage" link appears in dropdown menu
3. Click it
4. **Expected:** Navigates to /admin/usage
### Test 10: Config — Rate Limit Thresholds
1. Verify settings are configurable:
```bash
docker exec chrysopedia-api python -c "
from config import get_settings
s = get_settings()
print(s.rate_limit_user_per_hour, s.rate_limit_ip_per_hour, s.rate_limit_creator_per_hour)
"
```
2. **Expected:** Prints `30 10 60` (defaults)
3. Set env var `RATE_LIMIT_IP_PER_HOUR=5`, restart API
4. **Expected:** New threshold takes effect — 6th anonymous request returns 429
### Edge Cases
- **Missing user + missing IP:** Rate limiter should use a fallback key, not crash
- **Exactly at limit:** 10th anonymous request (at ip limit of 10) should succeed; 11th should get 429
- **Multiple rate limit tiers:** Authenticated user with IP also hitting creator limit — first limit hit returns 429

View file

@ -0,0 +1,16 @@
{
"schemaVersion": 1,
"taskId": "T02",
"unitId": "M025/S04/T02",
"timestamp": 1775310284611,
"passed": true,
"discoverySource": "task-plan",
"checks": [
{
"command": "echo 'endpoint ok'",
"exitCode": 0,
"durationMs": 11,
"verdict": "pass"
}
]
}

View file

@ -1,6 +1,92 @@
# S05: [B] AI Transparency Page
**Goal:** Build AI transparency page showing what the system knows about a creator's content
**Goal:** Creator sees all entities, relationships, and technique pages derived from their content on a dedicated transparency page
**Demo:** After this: Creator sees all entities, relationships, and technique pages derived from their content
## Tasks
- [x] **T01: Added GET /creator/transparency endpoint returning technique pages, key moments, relationships, source videos, and tags derived from a creator's content** — Add `GET /creator/transparency` endpoint to the existing creator_dashboard router. The endpoint queries all content derived from the authenticated creator's uploads: technique pages (with key moment counts), key moments (with source video filenames and linked technique page titles), cross-references (RelatedTechniqueLink where source or target page belongs to creator), source videos, and distinct topic tags. Add Pydantic response schemas to schemas.py.
## Steps
1. Read `backend/schemas.py` to find the CreatorDashboard schemas and add new transparency schemas below them:
- `TransparencyTechnique`: title, slug, topic_category, topic_tags, summary (truncated to 200 chars), created_at, key_moment_count
- `TransparencyKeyMoment`: title, summary, content_type, start_time, end_time, source_video_filename, technique_page_title (nullable)
- `TransparencyRelationship`: relationship_type, source_page_title, source_page_slug, target_page_title, target_page_slug
- `TransparencySourceVideo`: filename, processing_status, created_at
- `CreatorTransparencyResponse`: techniques list, key_moments list, relationships list, source_videos list, tags (list[str])
2. Read `backend/routers/creator_dashboard.py` and add a new `GET /transparency` endpoint below the existing dashboard endpoint. Follow the same auth pattern (get_current_user dependency, creator_id check, 404 if no linked creator).
3. Query technique pages with `selectinload(TechniquePage.key_moments)` and `selectinload(TechniquePage.outgoing_links)` and `selectinload(TechniquePage.incoming_links)`. For key moments, join through SourceVideo to get filename. For relationships, collect both outgoing and incoming links from the creator's technique pages.
4. Collect distinct tags by flattening all technique page topic_tags arrays.
5. Add import of new schemas in creator_dashboard.py.
## Must-Haves
- [ ] Auth-guarded endpoint at `GET /creator/transparency`
- [ ] Response includes technique pages, key moments, relationships, source videos, tags
- [ ] No N+1 queries — use selectinload for relationships
- [ ] 404 when user has no linked creator profile
## Verification
- `docker exec chrysopedia-api python -c "from routers.creator_dashboard import router; from schemas import CreatorTransparencyResponse"` — import check passes
- `docker cp` the changed files into the container, restart, and `curl -H 'Authorization: Bearer $TOKEN' http://ub01:8096/api/v1/creator/transparency` returns 200 with the expected JSON shape
- Estimate: 45m
- Files: backend/schemas.py, backend/routers/creator_dashboard.py
- Verify: docker exec chrysopedia-api python -c "from routers.creator_dashboard import router; from schemas import CreatorTransparencyResponse" && echo 'OK'
- [ ] **T02: Build transparency page with collapsible sections and wire into creator dashboard** — Create the CreatorTransparency page component with four collapsible sections (Technique Pages, Key Moments, Cross-References, Source Videos), an API client function, route registration in App.tsx, and a sidebar nav link.
## Steps
1. Create `frontend/src/api/creator-transparency.ts` following the exact pattern from `frontend/src/api/creator-dashboard.ts`:
- Define TypeScript interfaces matching the backend schemas (TransparencyTechnique, TransparencyKeyMoment, TransparencyRelationship, TransparencySourceVideo, CreatorTransparencyResponse)
- Export `fetchCreatorTransparency()` function calling `GET ${BASE}/creator/transparency`
2. Create `frontend/src/pages/CreatorTransparency.module.css` with styles for:
- Page layout (reuse sidebar pattern from CreatorDashboard.module.css)
- Collapsible section headers with chevron toggle
- Table/list styles for each data category
- Content type badges for key moments
- Relationship type labels
3. Create `frontend/src/pages/CreatorTransparency.tsx`:
- Import SidebarNav from CreatorDashboard.tsx
- Use useAuth hook for auth check, useDocumentTitle for page title
- Fetch data with fetchCreatorTransparency on mount
- Render four collapsible sections using useState toggles:
a. Technique Pages — table with title (linked to /techniques/:slug), category, tags (max 4 + overflow), key moment count, created date
b. Key Moments — grouped by source video, showing title, content_type badge, time range, linked technique page
c. Cross-References — table of relationship_type + source → target (both linked)
d. Source Videos — list with processing status badge and created date
- Show tag summary at top (distinct tags as pills)
- Handle loading, error, and empty states
4. Add route in `frontend/src/App.tsx`:
- Add lazy import: `const CreatorTransparency = lazy(() => import("./pages/CreatorTransparency"))`
- Add route: `<Route path="/creator/transparency" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><CreatorTransparency /></Suspense></ProtectedRoute>} />`
5. Add sidebar link in `frontend/src/pages/CreatorDashboard.tsx`:
- Add a "Transparency" NavLink in the SidebarNav component with an eye/search icon SVG, positioned after "Tiers" and before "Posts"
6. Run `cd frontend && npm run build` to verify zero TypeScript errors.
## Must-Haves
- [ ] API client fetches from /creator/transparency endpoint
- [ ] Page renders four collapsible sections with real data shapes
- [ ] SidebarNav includes Transparency link
- [ ] Route registered in App.tsx with ProtectedRoute wrapper
- [ ] Frontend builds with zero TypeScript errors
- [ ] Technique titles link to public technique pages
## Verification
- `cd frontend && npm run build` exits 0 with no errors
- `grep -q 'transparency' frontend/src/App.tsx` confirms route registered
- `grep -q 'Transparency' frontend/src/pages/CreatorDashboard.tsx` confirms sidebar link added
- Estimate: 1h
- Files: frontend/src/api/creator-transparency.ts, frontend/src/pages/CreatorTransparency.tsx, frontend/src/pages/CreatorTransparency.module.css, frontend/src/App.tsx, frontend/src/pages/CreatorDashboard.tsx
- Verify: cd frontend && npm run build && echo 'OK'

View file

@ -0,0 +1,64 @@
# S05 Research — AI Transparency Page
## Summary
Straightforward slice. A new creator-facing page that shows everything the AI pipeline derived from their content: technique pages, key moments, cross-references (RelatedTechniqueLink), tags, and source videos. No new technology — this is a read-only view over existing DB models exposed through a new API endpoint and rendered in a new React page within the existing creator dashboard shell.
No active requirements are directly owned by this slice. It supports R015 (retrieval target) indirectly by giving creators visibility into what's searchable.
## Recommendation
Add a single new API endpoint `GET /creator/transparency` (auth-required, scoped to logged-in creator). Build one new React page `CreatorTransparency.tsx` with the sidebar nav from `CreatorDashboard.tsx`. Register the route in `App.tsx`.
## Implementation Landscape
### Backend — New endpoint
**File:** `backend/routers/creator_dashboard.py` (add to existing router)
The endpoint queries everything derived from a creator's content:
- **Technique pages:** `TechniquePage` where `creator_id = current_user.creator_id` — title, slug, topic_category, topic_tags, summary, created_at, key_moment count
- **Key moments:** `KeyMoment` joined through `SourceVideo.creator_id` — title, summary, content_type, start_time/end_time, source video filename, linked technique page title
- **Relationships (cross-references):** `RelatedTechniqueLink` where source or target page belongs to the creator — relationship type, source page title, target page title
- **Source videos:** `SourceVideo` where `creator_id` — filename, processing_status, created_at
- **Tags:** Distinct `topic_tags` from all creator's technique pages (these are ARRAY columns, not Tag table)
Use `selectinload` for technique_page → key_moments and technique_page → outgoing_links/incoming_links to avoid N+1. The dashboard endpoint (`get_creator_dashboard`) already demonstrates the auth + creator_id pattern.
**Response schema:** Add `CreatorTransparencyResponse` to `backend/schemas.py` with nested lists.
### Frontend — New page
**File:** `frontend/src/pages/CreatorTransparency.tsx` + `.module.css`
Uses `SidebarNav` from `CreatorDashboard.tsx` (already exported). Four collapsible sections:
1. **Technique Pages** — table/cards showing title (linked to public page), category, tags, key moment count, created date
2. **Key Moments** — grouped by source video, showing title, content_type badge, time range, linked technique page
3. **Cross-References** — table of relationship_type + source_page → target_page
4. **Source Videos** — simple list with processing status
**API client:** `frontend/src/api/creator-transparency.ts` — follows `creator-dashboard.ts` pattern exactly.
**Route:** Add `<Route path="/creator/transparency" ... />` in `App.tsx` alongside existing creator routes.
**Sidebar:** Add "Transparency" nav link in `SidebarNav` (in `CreatorDashboard.tsx`).
### Key patterns to follow
- Auth guard: `get_current_user` dependency + `current_user.creator_id` check (see `creator_dashboard.py` lines 33-50)
- API client: `import { request, BASE } from "./client"` pattern (see `creator-dashboard.ts`)
- Route registration: `<ProtectedRoute><Suspense>` wrapper (see `App.tsx` creator routes)
- CSS modules: `.module.css` co-located with page component
- SidebarNav: already exported from `CreatorDashboard.tsx`, add one more NavLink entry
### Natural task decomposition
1. **Backend endpoint + schema**`creator_dashboard.py` + `schemas.py` (can test with curl independently)
2. **Frontend page + API client + route**`CreatorTransparency.tsx` + CSS + `creator-transparency.ts` + `App.tsx` + sidebar link
### Verification
- `docker exec chrysopedia-api python -c "from routers.creator_dashboard import router"` — import check
- `curl -H "Authorization: Bearer $TOKEN" http://ub01:8096/api/v1/creator/transparency` — endpoint returns data
- Frontend build: `cd frontend && npm run build` — no TypeScript errors
- Browser: navigate to `/creator/transparency` while logged in as a creator — page renders with sections

View file

@ -0,0 +1,53 @@
---
estimated_steps: 20
estimated_files: 2
skills_used: []
---
# T01: Add transparency API endpoint and response schemas
Add `GET /creator/transparency` endpoint to the existing creator_dashboard router. The endpoint queries all content derived from the authenticated creator's uploads: technique pages (with key moment counts), key moments (with source video filenames and linked technique page titles), cross-references (RelatedTechniqueLink where source or target page belongs to creator), source videos, and distinct topic tags. Add Pydantic response schemas to schemas.py.
## Steps
1. Read `backend/schemas.py` to find the CreatorDashboard schemas and add new transparency schemas below them:
- `TransparencyTechnique`: title, slug, topic_category, topic_tags, summary (truncated to 200 chars), created_at, key_moment_count
- `TransparencyKeyMoment`: title, summary, content_type, start_time, end_time, source_video_filename, technique_page_title (nullable)
- `TransparencyRelationship`: relationship_type, source_page_title, source_page_slug, target_page_title, target_page_slug
- `TransparencySourceVideo`: filename, processing_status, created_at
- `CreatorTransparencyResponse`: techniques list, key_moments list, relationships list, source_videos list, tags (list[str])
2. Read `backend/routers/creator_dashboard.py` and add a new `GET /transparency` endpoint below the existing dashboard endpoint. Follow the same auth pattern (get_current_user dependency, creator_id check, 404 if no linked creator).
3. Query technique pages with `selectinload(TechniquePage.key_moments)` and `selectinload(TechniquePage.outgoing_links)` and `selectinload(TechniquePage.incoming_links)`. For key moments, join through SourceVideo to get filename. For relationships, collect both outgoing and incoming links from the creator's technique pages.
4. Collect distinct tags by flattening all technique page topic_tags arrays.
5. Add import of new schemas in creator_dashboard.py.
## Must-Haves
- [ ] Auth-guarded endpoint at `GET /creator/transparency`
- [ ] Response includes technique pages, key moments, relationships, source videos, tags
- [ ] No N+1 queries — use selectinload for relationships
- [ ] 404 when user has no linked creator profile
## Verification
- `docker exec chrysopedia-api python -c "from routers.creator_dashboard import router; from schemas import CreatorTransparencyResponse"` — import check passes
- `docker cp` the changed files into the container, restart, and `curl -H 'Authorization: Bearer $TOKEN' http://ub01:8096/api/v1/creator/transparency` returns 200 with the expected JSON shape
## Inputs
- ``backend/schemas.py` — existing CreatorDashboard* schemas to follow pattern`
- ``backend/routers/creator_dashboard.py` — existing dashboard endpoint with auth pattern`
- ``backend/models.py` — TechniquePage, KeyMoment, RelatedTechniqueLink, SourceVideo models`
## Expected Output
- ``backend/schemas.py` — new Transparency* response schemas added`
- ``backend/routers/creator_dashboard.py` — new GET /transparency endpoint added`
## Verification
docker exec chrysopedia-api python -c "from routers.creator_dashboard import router; from schemas import CreatorTransparencyResponse" && echo 'OK'

View file

@ -0,0 +1,79 @@
---
id: T01
parent: S05
milestone: M025
provides: []
requires: []
affects: []
key_files: ["backend/schemas.py", "backend/routers/creator_dashboard.py"]
key_decisions: ["Collect unlinked key moments separately to show all moments from creator's videos", "Use selectinload chains to avoid N+1 queries for relationships"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "Import check passed in container. HTTP 200 with correct JSON shape for creator user (2 techniques, 30 key moments, 3 videos, 5 tags). HTTP 404 for admin user without linked creator profile."
completed_at: 2026-04-04T13:55:09.888Z
blocker_discovered: false
---
# T01: Added GET /creator/transparency endpoint returning technique pages, key moments, relationships, source videos, and tags derived from a creator's content
> Added GET /creator/transparency endpoint returning technique pages, key moments, relationships, source videos, and tags derived from a creator's content
## What Happened
---
id: T01
parent: S05
milestone: M025
key_files:
- backend/schemas.py
- backend/routers/creator_dashboard.py
key_decisions:
- Collect unlinked key moments separately to show all moments from creator's videos
- Use selectinload chains to avoid N+1 queries for relationships
duration: ""
verification_result: passed
completed_at: 2026-04-04T13:55:09.888Z
blocker_discovered: false
---
# T01: Added GET /creator/transparency endpoint returning technique pages, key moments, relationships, source videos, and tags derived from a creator's content
**Added GET /creator/transparency endpoint returning technique pages, key moments, relationships, source videos, and tags derived from a creator's content**
## What Happened
Added five Pydantic response schemas and a new GET /creator/transparency endpoint to the creator_dashboard router. The endpoint eager-loads the full entity graph from the creator's ID using selectinload chains (technique pages → key moments → source videos, outgoing/incoming links → linked pages), then flattens into response sections. Also queries unlinked key moments separately and collects distinct tags from all technique pages.
## Verification
Import check passed in container. HTTP 200 with correct JSON shape for creator user (2 techniques, 30 key moments, 3 videos, 5 tags). HTTP 404 for admin user without linked creator profile.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `docker exec chrysopedia-api python -c "from routers.creator_dashboard import router; from schemas import CreatorTransparencyResponse"` | 0 | ✅ pass | 2000ms |
| 2 | `curl -H 'Authorization: Bearer $TOKEN' http://localhost:8096/api/v1/creator/transparency (creator user)` | 0 | ✅ pass (HTTP 200) | 3000ms |
| 3 | `curl -H 'Authorization: Bearer $TOKEN' http://localhost:8096/api/v1/creator/transparency (admin, no creator)` | 0 | ✅ pass (HTTP 404) | 1000ms |
## Deviations
Added unlinked key moments query (moments with technique_page_id IS NULL) — not in plan but needed for complete transparency view.
## Known Issues
None.
## Files Created/Modified
- `backend/schemas.py`
- `backend/routers/creator_dashboard.py`
## Deviations
Added unlinked key moments query (moments with technique_page_id IS NULL) — not in plan but needed for complete transparency view.
## Known Issues
None.

View file

@ -0,0 +1,78 @@
---
estimated_steps: 39
estimated_files: 5
skills_used: []
---
# T02: Build transparency page with collapsible sections and wire into creator dashboard
Create the CreatorTransparency page component with four collapsible sections (Technique Pages, Key Moments, Cross-References, Source Videos), an API client function, route registration in App.tsx, and a sidebar nav link.
## Steps
1. Create `frontend/src/api/creator-transparency.ts` following the exact pattern from `frontend/src/api/creator-dashboard.ts`:
- Define TypeScript interfaces matching the backend schemas (TransparencyTechnique, TransparencyKeyMoment, TransparencyRelationship, TransparencySourceVideo, CreatorTransparencyResponse)
- Export `fetchCreatorTransparency()` function calling `GET ${BASE}/creator/transparency`
2. Create `frontend/src/pages/CreatorTransparency.module.css` with styles for:
- Page layout (reuse sidebar pattern from CreatorDashboard.module.css)
- Collapsible section headers with chevron toggle
- Table/list styles for each data category
- Content type badges for key moments
- Relationship type labels
3. Create `frontend/src/pages/CreatorTransparency.tsx`:
- Import SidebarNav from CreatorDashboard.tsx
- Use useAuth hook for auth check, useDocumentTitle for page title
- Fetch data with fetchCreatorTransparency on mount
- Render four collapsible sections using useState toggles:
a. Technique Pages — table with title (linked to /techniques/:slug), category, tags (max 4 + overflow), key moment count, created date
b. Key Moments — grouped by source video, showing title, content_type badge, time range, linked technique page
c. Cross-References — table of relationship_type + source → target (both linked)
d. Source Videos — list with processing status badge and created date
- Show tag summary at top (distinct tags as pills)
- Handle loading, error, and empty states
4. Add route in `frontend/src/App.tsx`:
- Add lazy import: `const CreatorTransparency = lazy(() => import("./pages/CreatorTransparency"))`
- Add route: `<Route path="/creator/transparency" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><CreatorTransparency /></Suspense></ProtectedRoute>} />`
5. Add sidebar link in `frontend/src/pages/CreatorDashboard.tsx`:
- Add a "Transparency" NavLink in the SidebarNav component with an eye/search icon SVG, positioned after "Tiers" and before "Posts"
6. Run `cd frontend && npm run build` to verify zero TypeScript errors.
## Must-Haves
- [ ] API client fetches from /creator/transparency endpoint
- [ ] Page renders four collapsible sections with real data shapes
- [ ] SidebarNav includes Transparency link
- [ ] Route registered in App.tsx with ProtectedRoute wrapper
- [ ] Frontend builds with zero TypeScript errors
- [ ] Technique titles link to public technique pages
## Verification
- `cd frontend && npm run build` exits 0 with no errors
- `grep -q 'transparency' frontend/src/App.tsx` confirms route registered
- `grep -q 'Transparency' frontend/src/pages/CreatorDashboard.tsx` confirms sidebar link added
## Inputs
- ``frontend/src/api/creator-dashboard.ts` — API client pattern to follow`
- ``frontend/src/pages/CreatorDashboard.tsx` — SidebarNav component to import and extend`
- ``frontend/src/pages/CreatorDashboard.module.css` — CSS module pattern to follow`
- ``frontend/src/App.tsx` — route registration pattern`
- ``backend/schemas.py` — new Transparency* schemas defining the API response shape`
## Expected Output
- ``frontend/src/api/creator-transparency.ts` — API client with types and fetch function`
- ``frontend/src/pages/CreatorTransparency.tsx` — transparency page component`
- ``frontend/src/pages/CreatorTransparency.module.css` — page styles`
- ``frontend/src/App.tsx` — new route added`
- ``frontend/src/pages/CreatorDashboard.tsx` — sidebar nav link added`
## Verification
cd frontend && npm run build && echo 'OK'

View file

@ -10,12 +10,14 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, 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 (
Creator,
KeyMoment,
RelatedTechniqueLink,
SearchLog,
SourceVideo,
TechniquePage,
@ -25,6 +27,11 @@ from schemas import (
CreatorDashboardResponse,
CreatorDashboardTechnique,
CreatorDashboardVideo,
CreatorTransparencyResponse,
TransparencyKeyMoment,
TransparencyRelationship,
TransparencySourceVideo,
TransparencyTechnique,
)
logger = logging.getLogger("chrysopedia.creator_dashboard")
@ -160,3 +167,154 @@ async def get_creator_dashboard(
techniques=techniques,
videos=videos,
)
@router.get("/transparency", response_model=CreatorTransparencyResponse)
async def get_creator_transparency(
current_user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_session),
) -> CreatorTransparencyResponse:
"""Return all entities derived from the authenticated creator's content.
Shows technique pages, key moments, cross-reference relationships,
source videos, and aggregated topic tags everything the AI pipeline
produced from this creator's uploads.
"""
if current_user.creator_id is None:
raise HTTPException(
status_code=404,
detail="No creator profile linked to this account",
)
creator_id = current_user.creator_id
# Verify creator exists
creator = (await db.execute(
select(Creator).where(Creator.id == creator_id)
)).scalar_one_or_none()
if creator is None:
logger.error("User %s has creator_id %s but creator row missing", current_user.id, creator_id)
raise HTTPException(
status_code=404,
detail="Linked creator profile not found",
)
# ── Technique pages with key moment counts ───────────────────────────
technique_pages = (await db.execute(
select(TechniquePage)
.where(TechniquePage.creator_id == creator_id)
.options(
selectinload(TechniquePage.key_moments).selectinload(KeyMoment.source_video),
selectinload(TechniquePage.outgoing_links).selectinload(RelatedTechniqueLink.target_page),
selectinload(TechniquePage.incoming_links).selectinload(RelatedTechniqueLink.source_page),
)
.order_by(TechniquePage.created_at.desc())
)).scalars().all()
techniques = []
all_key_moments: list[TransparencyKeyMoment] = []
all_relationships: list[TransparencyRelationship] = []
all_tags: set[str] = set()
for tp in technique_pages:
techniques.append(TransparencyTechnique(
title=tp.title,
slug=tp.slug,
topic_category=tp.topic_category,
topic_tags=tp.topic_tags or [],
summary=(tp.summary or "")[:200],
created_at=tp.created_at,
key_moment_count=len(tp.key_moments),
))
# Collect tags
if tp.topic_tags:
all_tags.update(tp.topic_tags)
# Key moments from this technique page
for km in tp.key_moments:
all_key_moments.append(TransparencyKeyMoment(
title=km.title,
summary=km.summary,
content_type=km.content_type.value if hasattr(km.content_type, 'value') else str(km.content_type),
start_time=km.start_time,
end_time=km.end_time,
source_video_filename=km.source_video.filename if km.source_video else "",
technique_page_title=tp.title,
))
# Outgoing relationships
for link in tp.outgoing_links:
all_relationships.append(TransparencyRelationship(
relationship_type=link.relationship.value if hasattr(link.relationship, 'value') else str(link.relationship),
source_page_title=tp.title,
source_page_slug=tp.slug,
target_page_title=link.target_page.title if link.target_page else "",
target_page_slug=link.target_page.slug if link.target_page else "",
))
# Incoming relationships
for link in tp.incoming_links:
all_relationships.append(TransparencyRelationship(
relationship_type=link.relationship.value if hasattr(link.relationship, 'value') else str(link.relationship),
source_page_title=link.source_page.title if link.source_page else "",
source_page_slug=link.source_page.slug if link.source_page else "",
target_page_title=tp.title,
target_page_slug=tp.slug,
))
# ── Key moments not linked to a technique page ───────────────────────
# (moments from creator's videos that haven't been assigned to a page)
unlinked_moments = (await db.execute(
select(KeyMoment)
.join(SourceVideo, KeyMoment.source_video_id == SourceVideo.id)
.where(
SourceVideo.creator_id == creator_id,
KeyMoment.technique_page_id.is_(None),
)
.options(selectinload(KeyMoment.source_video))
)).scalars().all()
for km in unlinked_moments:
all_key_moments.append(TransparencyKeyMoment(
title=km.title,
summary=km.summary,
content_type=km.content_type.value if hasattr(km.content_type, 'value') else str(km.content_type),
start_time=km.start_time,
end_time=km.end_time,
source_video_filename=km.source_video.filename if km.source_video else "",
technique_page_title=None,
))
# ── Source videos ────────────────────────────────────────────────────
video_rows = (await db.execute(
select(SourceVideo)
.where(SourceVideo.creator_id == creator_id)
.order_by(SourceVideo.created_at.desc())
)).scalars().all()
source_videos = [
TransparencySourceVideo(
filename=v.filename,
processing_status=v.processing_status.value if hasattr(v.processing_status, 'value') else str(v.processing_status),
created_at=v.created_at,
)
for v in video_rows
]
logger.info(
"Transparency loaded for creator %s: %d techniques, %d moments, %d relationships, %d videos, %d tags",
creator_id, len(techniques), len(all_key_moments),
len(all_relationships), len(source_videos), len(all_tags),
)
return CreatorTransparencyResponse(
techniques=techniques,
key_moments=all_key_moments,
relationships=all_relationships,
source_videos=source_videos,
tags=sorted(all_tags),
)

View file

@ -862,3 +862,52 @@ class NotificationPreferencesUpdate(BaseModel):
"""Partial update for notification preferences."""
email_digests: bool | None = None
digest_frequency: str | None = None
# ── Creator Transparency ─────────────────────────────────────────────────────
class TransparencyTechnique(BaseModel):
"""Technique page derived from creator's content."""
title: str
slug: str
topic_category: str
topic_tags: list[str] = Field(default_factory=list)
summary: str = ""
created_at: datetime
key_moment_count: int = 0
class TransparencyKeyMoment(BaseModel):
"""Key moment extracted from creator's videos."""
title: str
summary: str
content_type: str
start_time: float
end_time: float
source_video_filename: str = ""
technique_page_title: str | None = None
class TransparencyRelationship(BaseModel):
"""Cross-reference link involving creator's technique pages."""
relationship_type: str
source_page_title: str
source_page_slug: str
target_page_title: str
target_page_slug: str
class TransparencySourceVideo(BaseModel):
"""Source video uploaded by creator."""
filename: str
processing_status: str
created_at: datetime
class CreatorTransparencyResponse(BaseModel):
"""Full transparency payload — all entities derived from a creator's content."""
techniques: list[TransparencyTechnique] = Field(default_factory=list)
key_moments: list[TransparencyKeyMoment] = Field(default_factory=list)
relationships: list[TransparencyRelationship] = Field(default_factory=list)
source_videos: list[TransparencySourceVideo] = Field(default_factory=list)
tags: list[str] = Field(default_factory=list)