# S05: Search-First Web UI

**Goal:** User searches for a technique, gets semantic results in <500ms, clicks through to a full technique page with study guide prose, key moments, and related links. Browse pages for creators (randomized default sort) and topics (two-level hierarchy) are navigable.
**Demo:** After this: User searches for a technique, gets semantic results in <500ms, clicks through to a full technique page with study guide prose, key moments, and related links

## Tasks
- [x] **T01: Created async search service with embedding+Qdrant+keyword fallback and all public API endpoints (search, techniques, topics, enhanced creators) mounted at /api/v1** — ## Description

Create the backend API surface for S05: the async search service (embedding + Qdrant), search endpoint, technique pages CRUD, topics hierarchy, and enhanced creators endpoint. This is the highest-risk task because it introduces async embedding/Qdrant clients for the FastAPI request path (existing ones are sync for Celery).

## Failure Modes

| Dependency | On error | On timeout | On malformed response |
|------------|----------|-----------|----------------------|
| Embedding API (AsyncOpenAI) | Fall back to keyword-only search | 300ms timeout → keyword fallback | Return empty vectors → keyword fallback |
| Qdrant (AsyncQdrantClient) | Fall back to keyword-only search | 300ms timeout → keyword fallback | Log warning, return empty results → keyword fallback |
| PostgreSQL | Return 500 (standard FastAPI error handling) | Connection pool timeout → 500 | N/A (SQLAlchemy typed) |

## Load Profile

- **Shared resources**: AsyncQdrantClient connection pool, AsyncOpenAI HTTP pool, SQLAlchemy async session pool
- **Per-operation cost**: Search = 1 embedding API call + 1 Qdrant query + 1-3 SQL queries for enrichment. Read endpoints = 1-2 SQL queries each.
- **10x breakpoint**: Embedding API rate limiting (external dependency). Mitigated by client-side debounce (300ms) reducing request rate.

## Negative Tests

- **Malformed inputs**: Empty search query → return empty results. Query > 500 chars → truncate to 500. Invalid scope parameter → default to 'all'.
- **Error paths**: Embedding API unreachable → keyword fallback. Qdrant unreachable → keyword fallback. Invalid slug → 404.
- **Boundary conditions**: Empty Qdrant collection → keyword-only results. Zero matching techniques/creators → empty list.

## Steps

1. Create `backend/search_service.py` with `SearchService` class:
   - `__init__` takes Settings, creates `openai.AsyncOpenAI` client and `qdrant_client.AsyncQdrantClient`
   - `async embed_query(text: str) -> list[float] | None` — embeds query text with 300ms timeout, returns None on failure
   - `async search_qdrant(vector: list[float], limit: int, type_filter: str | None) -> list[dict]` — queries Qdrant with optional payload type filter, returns scored results with payloads
   - `async keyword_search(query: str, scope: str, limit: int, db: AsyncSession) -> list[dict]` — ILIKE search on technique_pages.title, key_moments.title, creators.name
   - `async search(query: str, scope: str, limit: int, db: AsyncSession) -> dict` — orchestrates: embed → Qdrant → enrich with DB metadata → fallback to keyword if needed

2. Add new Pydantic response schemas to `backend/schemas.py`:
   - `SearchResultItem(title, slug, type, score, summary, creator_name, creator_slug, topic_category, topic_tags)`
   - `SearchResponse(items: list[SearchResultItem], total: int, query: str, fallback_used: bool)`
   - `TechniquePageDetail` (extends TechniquePageRead with nested key_moments, creator info, related links)
   - `TopicCategory(name, description, sub_topics: list[TopicSubTopic])` and `TopicSubTopic(name, technique_count, creator_count)`
   - `CreatorBrowseItem` (extends CreatorRead with technique_count, video_count)

3. Create `backend/routers/search.py`:
   - `GET /search?q=...&scope=all|topics|creators&limit=20`
   - Instantiate SearchService from get_settings(), call search(), return SearchResponse
   - Log query, latency_ms, result_count, fallback_used at INFO level

4. Create `backend/routers/techniques.py`:
   - `GET /techniques` — list technique pages with optional `category`, `creator_slug` query filters, pagination
   - `GET /techniques/{slug}` — full detail with eager-loaded key_moments (ordered by start_time), creator info, outgoing+incoming related links
   - Return 404 for unknown slug

5. Create `backend/routers/topics.py`:
   - `GET /topics` — load `canonical_tags.yaml`, for each category aggregate technique_count and creator_count per sub_topic from DB
   - `GET /topics/{category_slug}` — return technique pages filtered by topic_category

6. Extend `backend/routers/creators.py`:
   - Add `sort` query param: `random` (default), `alpha`, `views`
   - Add `genre` query param for filtering by genre
   - Add technique_count and video_count subqueries to list endpoint
   - For `sort=random`, use `func.random()` ORDER BY (dataset is small, <100 creators)

7. Mount all new routers in `backend/main.py`:
   - `from routers import search, techniques, topics`
   - `app.include_router(search.router, prefix="/api/v1")`
   - `app.include_router(techniques.router, prefix="/api/v1")`
   - `app.include_router(topics.router, prefix="/api/v1")`

## Must-Haves

- [ ] SearchService with async embedding + Qdrant + keyword fallback
- [ ] GET /api/v1/search returns SearchResponse with enriched results
- [ ] GET /api/v1/techniques and GET /api/v1/techniques/{slug} with full detail
- [ ] GET /api/v1/topics returns category hierarchy with counts
- [ ] GET /api/v1/creators supports sort=random (default), genre filter, technique/video counts
- [ ] All new routers mounted in main.py
- [ ] Embedding/Qdrant failures gracefully degrade to keyword search

## Verification

- `cd backend && python -c "from search_service import SearchService; print('OK')"` — imports clean
- `cd backend && python -c "from routers.search import router; print(router.routes)"` — search router has routes
- `cd backend && python -c "from routers.techniques import router; print(router.routes)"` — techniques router has routes
- `cd backend && python -c "from routers.topics import router; print(router.routes)"` — topics router has routes
- `cd backend && python -c "from main import app; routes = [r.path for r in app.routes]; assert '/api/v1/search' in str(routes) or any('search' in str(r.path) for r in app.routes); print('Mounted')"` — routers mounted

## Observability Impact

- Signals added: INFO log per search query with latency_ms, result_count, fallback_used. WARNING on embedding/Qdrant failure with error details.
- How a future agent inspects this: `curl localhost:8001/api/v1/search?q=test` returns structured JSON with timing data
- Failure state exposed: fallback_used=true in search response indicates Qdrant/embedding degradation
  - Estimate: 2h
  - Files: backend/search_service.py, backend/schemas.py, backend/routers/search.py, backend/routers/techniques.py, backend/routers/topics.py, backend/routers/creators.py, backend/main.py
  - Verify: cd backend && python -c "from search_service import SearchService; from routers.search import router as sr; from routers.techniques import router as tr; from routers.topics import router as tpr; print('All imports OK')" && python -c "from main import app; print([r.path for r in app.routes])"
- [x] **T02: Added 18 integration tests for search and public API endpoints (techniques, topics, creators) — all 58 tests pass** — ## Description

Write integration tests for all new S05 backend endpoints: search (with mocked embedding API and Qdrant), techniques list/detail, topics hierarchy, and enhanced creators (randomized sort, genre filter, counts). Tests run against real PostgreSQL with the existing conftest.py fixtures. All 40 existing tests must continue to pass.

## Negative Tests

- **Malformed inputs**: Empty search query returns empty results. Invalid technique slug returns 404. Invalid topic category returns empty list.
- **Error paths**: Search with mocked embedding failure → keyword fallback results returned. Search with mocked Qdrant failure → keyword fallback.
- **Boundary conditions**: Search with no matching results → empty items list. Topics with no technique pages → zero counts. Creators list with no creators → empty list.

## Steps

1. Create `backend/tests/test_search.py`:
   - Fixture: seed DB with 2 creators, 3 technique pages (different categories/tags), 5 key moments
   - Test search endpoint with mocked SearchService that returns canned results → verify response shape (items, total, query, fallback_used)
   - Test search with empty query → returns empty results or validation error
   - Test search keyword fallback: mock embedding to return None → verify keyword results returned and fallback_used=true
   - Test search scope filtering (scope=topics returns only technique_page type results)

2. Create `backend/tests/test_public_api.py`:
   - Test GET /api/v1/techniques — returns list of technique pages, supports category filter
   - Test GET /api/v1/techniques/{slug} — returns full detail with key_moments, creator info, related links
   - Test GET /api/v1/techniques/{slug} with invalid slug → 404
   - Test GET /api/v1/topics — returns category hierarchy with counts matching seeded data
   - Test GET /api/v1/creators?sort=random — returns creators (verify all returned, order may vary)
   - Test GET /api/v1/creators?sort=alpha — returns creators in alphabetical order
   - Test GET /api/v1/creators?genre=Bass+music — returns only matching creators
   - Test GET /api/v1/creators/{slug} — returns detail with technique_count, video_count

3. Run full test suite: `cd backend && python -m pytest tests/ -v` — all 40 existing + new tests pass

## Must-Haves

- [ ] test_search.py with ≥4 tests covering happy path, empty query, keyword fallback, scope filter
- [ ] test_public_api.py with ≥8 tests covering techniques list/detail/404, topics hierarchy, creators sort/filter/detail
- [ ] All 40 existing tests still pass (regression)
- [ ] Tests use real PostgreSQL with seeded data (not mocked DB)

## Verification

- `cd backend && python -m pytest tests/test_search.py tests/test_public_api.py -v` — all new tests pass
- `cd backend && python -m pytest tests/ -v` — all tests pass (40 existing + new)
  - Estimate: 1.5h
  - Files: backend/tests/test_search.py, backend/tests/test_public_api.py, backend/tests/conftest.py
  - Verify: cd backend && python -m pytest tests/test_search.py tests/test_public_api.py -v && python -m pytest tests/ -v
- [x] **T03: Built frontend search flow: typed public API client, landing page with debounced typeahead, search results with grouped display, and full technique page with prose/key moments/signal chains/plugins/related links** — ## Description

Build the primary user flow: landing page with search bar → search results page → technique page detail. This is the R005/R006/R015 critical path. Includes the new typed API client for public endpoints, App.tsx routing with both admin and public routes, and 3 new page components with CSS.

The frontend uses React 18 + Vite + TypeScript with strict mode (`noUnusedLocals`, `noUnusedParameters`, `noUncheckedIndexedAccess`). Existing pattern: plain CSS in `App.css`, typed `fetch()` API client, React Router v6.

## Steps

1. Create `frontend/src/api/public-client.ts` — typed API client for public endpoints:
   - Types: `SearchResultItem`, `SearchResponse`, `TechniquePageDetail`, `KeyMomentSummary`, `CreatorInfo`, `RelatedLink`, `TopicCategory`, `TopicSubTopic`, `CreatorBrowseItem`
   - Functions: `searchApi(q, scope?, limit?)`, `fetchTechnique(slug)`, `fetchTechniques(params?)`, `fetchTopics()`, `fetchCreators(params?)`, `fetchCreator(slug)`
   - Reuse the `request<T>` helper pattern from existing `client.ts` (or extract shared helper)

2. Create `frontend/src/pages/Home.tsx` — landing page:
   - Prominent search bar (auto-focus on mount) with debounce (300ms)
   - Live typeahead: after 2+ chars, show top 5 results in dropdown below search bar
   - On Enter or "See all results" link, navigate to `/search?q=...`
   - Two navigation cards: "Topics" (links to `/topics`) and "Creators" (links to `/creators`)
   - "Recently Added" section showing last 5 technique pages (fetch from `/api/v1/techniques?limit=5`)

3. Create `frontend/src/pages/SearchResults.tsx` — full search results page:
   - Read `q` from URL search params
   - Display results grouped by type (technique_pages first, then key_moments)
   - Each result: title (linked to technique page), summary snippet, creator name, category/tags
   - Show "No results found" for empty results, "Showing keyword results" when fallback_used=true
   - Search bar at top for refining query

4. Create `frontend/src/pages/TechniquePage.tsx` — technique page display (R006):
   - Fetch technique by slug from URL params via `fetchTechnique(slug)`
   - Header: title, topic_category badge, topic_tags pills, creator name (linked to `/creators/{slug}`), source_quality indicator
   - Amber banner if source_quality === 'unstructured' (livestream-sourced)
   - Study guide prose: render `body_sections` JSONB — iterate Object.entries, render each section as `<h2>` + paragraph. Handle both string and object values gracefully.
   - Key moments index: ordered list with title, start_time→end_time, content_type badge, summary
   - Signal chains section (if present): render each chain as name + ordered steps
   - Plugins referenced (if present): pill list
   - Related techniques (if present): linked list
   - Loading state and 404 error state

5. Update `frontend/src/App.tsx` routing:
   - Import new pages
   - Add public routes: `/` → Home, `/search` → SearchResults, `/techniques/:slug` → TechniquePage
   - Keep admin routes at `/admin/*`
   - Update header: "Chrysopedia" title (not "Chrysopedia Admin"), nav links to Home, Topics, Creators, and Admin

6. Add CSS to `frontend/src/App.css` for new pages:
   - Search bar styles (large, centered on home, inline on results page)
   - Typeahead dropdown styles
   - Navigation cards (grid layout)
   - Technique page layout (readable prose width, section spacing)
   - Search result items (hover state, meta info)
   - Tag/badge pill styles
   - Loading and error states

## Must-Haves

- [ ] Typed public API client with all endpoint functions
- [ ] Landing page with search bar, typeahead, navigation cards, recently added
- [ ] Search results page with grouped results
- [ ] Technique page with all sections (header, prose, key moments, related links)
- [ ] App.tsx routes both public and admin paths
- [ ] `cd frontend && npx tsc -b` passes with zero errors

## Verification

- `cd frontend && npx tsc -b` — zero TypeScript errors
- `cd frontend && npm run build` — clean production build
- Verify files exist: `test -f frontend/src/api/public-client.ts && test -f frontend/src/pages/Home.tsx && test -f frontend/src/pages/SearchResults.tsx && test -f frontend/src/pages/TechniquePage.tsx`
  - Estimate: 2h
  - Files: frontend/src/api/public-client.ts, frontend/src/pages/Home.tsx, frontend/src/pages/SearchResults.tsx, frontend/src/pages/TechniquePage.tsx, frontend/src/App.tsx, frontend/src/App.css
  - Verify: cd frontend && npx tsc -b && npm run build && echo 'Frontend build OK'
- [x] **T04: Built CreatorsBrowse (randomized default sort, genre filter, name filter, sort toggle), CreatorDetail (creator info + technique list), and TopicsBrowse (two-level expandable hierarchy with counts and filter) — all routes registered, TypeScript clean, production build succeeds** — ## Description

Build the remaining browse pages: CreatorsBrowse (R007, R014 creator equity with randomized default sort), CreatorDetail, and TopicsBrowse (R008 two-level hierarchy). Then run final verification to confirm the full frontend builds cleanly and all requirements are covered.

## Steps

1. Create `frontend/src/pages/CreatorsBrowse.tsx` — creators browse page (R007, R014):
   - Fetch creators from `/api/v1/creators?sort=random` (default) via `fetchCreators()`
   - Genre filter pills at top (fetch unique genres from creator data, or hardcode from canonical_tags.yaml genres)
   - Type-to-narrow input that filters displayed creators client-side by name
   - Sort toggle: Random (default), Alphabetical, Views — each triggers re-fetch with `sort=random|alpha|views`
   - Each creator row: name, genre tags (pills), technique_count, video_count, view_count
   - Click row → navigate to `/creators/{slug}`
   - All creators get equal visual weight (no featured/highlighted creators) per R014

2. Create `frontend/src/pages/CreatorDetail.tsx` — creator detail page:
   - Fetch creator by slug via `fetchCreator(slug)` — shows name, genres, video_count, technique_count
   - Fetch creator's technique pages via `fetchTechniques({ creator_slug: slug })`
   - List technique pages with title (linked to `/techniques/{slug}`), category, tags, summary
   - Loading state and 404 error state

3. Create `frontend/src/pages/TopicsBrowse.tsx` — topics browse page (R008):
   - Fetch topics from `/api/v1/topics` via `fetchTopics()`
   - Two-level hierarchy: 6 top-level categories (Sound design, Mixing, Synthesis, Arrangement, Workflow, Mastering)
   - Each category expandable/collapsible, showing sub-topics
   - Each sub-topic shows technique_count and creator_count
   - Click sub-topic → navigate to `/search?q={sub_topic_name}&scope=topics` or filter technique list
   - Filter input at top to narrow categories/sub-topics

4. Update `frontend/src/App.tsx` — add routes for browse pages:
   - `/creators` → CreatorsBrowse
   - `/creators/:slug` → CreatorDetail
   - `/topics` → TopicsBrowse
   - Import new page components

5. Add CSS to `frontend/src/App.css` for browse pages:
   - Creator list styles (rows, genre pills, counts)
   - Creator detail page layout
   - Topics hierarchy styles (collapsible sections, sub-topic rows, counts)
   - Filter input styles
   - Sort toggle button group

6. Final verification:
   - `cd frontend && npx tsc -b` — zero errors
   - `cd frontend && npm run build` — clean build
   - Verify all 6 page files exist

## Must-Haves

- [ ] Creators browse page with randomized default sort (R014), genre filter, type-to-narrow, sort toggle (R007)
- [ ] Creator detail page showing creator info and their technique pages
- [ ] Topics browse page with two-level hierarchy, counts, clickable sub-topics (R008)
- [ ] All routes registered in App.tsx
- [ ] `cd frontend && npx tsc -b` passes with zero errors
- [ ] `cd frontend && npm run build` succeeds

## Verification

- `cd frontend && npx tsc -b && npm run build && echo 'Build OK'`
- `test -f frontend/src/pages/CreatorsBrowse.tsx && test -f frontend/src/pages/CreatorDetail.tsx && test -f frontend/src/pages/TopicsBrowse.tsx && echo 'All pages exist'`
  - Estimate: 1.5h
  - Files: frontend/src/pages/CreatorsBrowse.tsx, frontend/src/pages/CreatorDetail.tsx, frontend/src/pages/TopicsBrowse.tsx, frontend/src/App.tsx, frontend/src/App.css
  - Verify: cd frontend && npx tsc -b && npm run build && test -f src/pages/CreatorsBrowse.tsx && test -f src/pages/CreatorDetail.tsx && test -f src/pages/TopicsBrowse.tsx && echo 'All browse pages built OK'
