test: Added GET /api/v1/search/suggestions endpoint returning popular t…

- "backend/schemas.py"
- "backend/routers/search.py"
- "backend/tests/test_search.py"

GSD-Task: S04/T01
This commit is contained in:
jlightner 2026-03-31 06:35:37 +00:00
parent ec7e07c705
commit 1254e173d4
12 changed files with 631 additions and 3 deletions

View file

@ -8,5 +8,5 @@ Chrysopedia should feel like exploring a music production library, not querying
|----|-------|------|---------|------|------------| |----|-------|------|---------|------|------------|
| S01 | Dedicated Sub-Topic Pages | high | — | ✅ | Clicking 'Compression' on Topics page loads /topics/mixing/compression with techniques grouped by creator, description, breadcrumbs | | S01 | Dedicated Sub-Topic Pages | high | — | ✅ | Clicking 'Compression' on Topics page loads /topics/mixing/compression with techniques grouped by creator, description, breadcrumbs |
| S02 | Related Techniques Cross-Linking | medium | — | ✅ | Bottom of every technique page shows 3-4 related techniques from same creator or same sub-topic | | S02 | Related Techniques Cross-Linking | medium | — | ✅ | Bottom of every technique page shows 3-4 related techniques from same creator or same sub-topic |
| S03 | Topic Color Coding & Visual Polish | medium | S01 | | Each of 7 topic categories has a distinct accent color in badges, cards, and sub-topic pages. Page transitions are smooth. Creator avatars are visually unique. | | S03 | Topic Color Coding & Visual Polish | medium | S01 | | Each of 7 topic categories has a distinct accent color in badges, cards, and sub-topic pages. Page transitions are smooth. Creator avatars are visually unique. |
| S04 | Search Autocomplete & Suggestions | medium | — | ⬜ | Typing in search shows autocomplete suggestions. Empty search box shows popular search terms. | | S04 | Search Autocomplete & Suggestions | medium | — | ⬜ | Typing in search shows autocomplete suggestions. Empty search box shows popular search terms. |

View file

@ -0,0 +1,90 @@
---
id: S03
parent: M010
milestone: M010
provides:
- Category accent colors on SubTopicPage (border + badge) and SearchResults (badge)
- Page-enter fade-in animation on all 7 public pages
- Shared catSlug utility at frontend/src/utils/catSlug.ts
requires:
- slice: S01
provides: SubTopicPage component and .subtopic-page CSS class
affects:
[]
key_files:
- frontend/src/utils/catSlug.ts
- frontend/src/pages/SubTopicPage.tsx
- frontend/src/pages/SearchResults.tsx
- frontend/src/pages/TopicsBrowse.tsx
- frontend/src/App.css
key_decisions:
- Extracted catSlug into shared utils/ directory — establishes pattern for cross-page helper reuse
- Applied page-enter animation via CSS selector list rather than adding className to each TSX file — zero component churn
- Placed category badge in subtitle row for clean visual hierarchy on SubTopicPage
patterns_established:
- Shared utility pattern: frontend/src/utils/ for cross-page helpers
- CSS-only page transitions via selector list targeting existing wrapper classes — no component changes needed
observability_surfaces:
- none
drill_down_paths:
- .gsd/milestones/M010/slices/S03/tasks/T01-SUMMARY.md
- .gsd/milestones/M010/slices/S03/tasks/T02-SUMMARY.md
duration: ""
verification_result: passed
completed_at: 2026-03-31T06:28:12.150Z
blocker_discovered: false
---
# S03: Topic Color Coding & Visual Polish
**Added per-category accent colors (border + badge) to SubTopicPage and SearchResults, extracted catSlug to shared utility, and applied CSS-only page-enter fade-in animation to all 7 public pages.**
## What Happened
This slice delivered two complementary visual polish features:
**T01 — Category accent colors.** Extracted the `catSlug` helper from TopicsBrowse.tsx into `frontend/src/utils/catSlug.ts` for cross-page reuse. Applied category accent colors in two places: (1) SubTopicPage gets a 4px colored left border using `var(--color-badge-cat-{slug}-text)` and a colored category badge in the subtitle row; (2) SearchResults cards now render `topic_category` as a colored `badge--cat-{slug}` badge instead of plain text. All 7 category slugs (sound-design, mixing, synthesis, arrangement, workflow, mastering, music-theory) work against the CSS custom properties established in M004/S02.
**T02 — Page enter transitions.** Added a `@keyframes pageEnter` animation (opacity 0→1, translateY 8px→0, 250ms ease-out) applied via a CSS selector list targeting all 7 public page wrapper classes (.home, .topics-browse, .subtopic-page, .search-results-page, .creators-browse, .creator-detail, .technique-page). No TSX files were modified — the animation triggers on mount via existing class names.
Both tasks pass TypeScript type-checking and Vite production build with zero errors.
## Verification
TypeScript compilation (`npx tsc --noEmit`) and Vite production build (`npm run build`) both pass with zero errors. 49 modules transformed, production bundle produced at dist/. Verified catSlug utility is imported correctly by TopicsBrowse, SubTopicPage, and SearchResults. Verified pageEnter animation targets all 7 page wrapper classes.
## Requirements Advanced
None.
## Requirements Validated
None.
## New Requirements Surfaced
None.
## Requirements Invalidated or Re-scoped
None.
## Deviations
None. T02 used the CSS selector list approach (plan option #4) to avoid touching 7 TSX files — this was explicitly offered as an alternative in the plan.
## Known Limitations
None.
## Follow-ups
None.
## Files Created/Modified
- `frontend/src/utils/catSlug.ts` — New shared utility exporting catSlug(name) → CSS slug
- `frontend/src/pages/TopicsBrowse.tsx` — Import catSlug from shared utility instead of local definition
- `frontend/src/pages/SubTopicPage.tsx` — Added colored left border and category badge using catSlug
- `frontend/src/pages/SearchResults.tsx` — Replaced plain topic_category text with colored badge
- `frontend/src/App.css` — Added @keyframes pageEnter animation and applied to all 7 page wrapper classes; added subtopic-page border styles

View file

@ -0,0 +1,52 @@
# S03: Topic Color Coding & Visual Polish — UAT
**Milestone:** M010
**Written:** 2026-03-31T06:28:12.150Z
# S03 UAT: Topic Color Coding & Visual Polish
## Preconditions
- Chrysopedia frontend is running (http://ub01:8096 or local dev server)
- Database contains technique pages across multiple topic categories
---
## Test 1: Category accent colors on SubTopicPage
1. Navigate to Topics page → click any top-level category (e.g., Mixing)
2. Click a sub-topic (e.g., Compression)
3. **Expected:** SubTopicPage has a 4px colored left border matching the category's accent color
4. **Expected:** A colored badge showing the category name (e.g., "Mixing") appears in the subtitle area
5. Repeat for a different category (e.g., Sound Design → Layering)
6. **Expected:** The border and badge color change to match the new category
## Test 2: Category badges in search results
1. Navigate to Search, enter a broad query (e.g., "reverb")
2. **Expected:** Each result card shows the topic_category as a colored badge (not plain text)
3. **Expected:** Cards from different categories show different badge colors
4. Verify all 7 category slugs render correctly: sound-design, mixing, synthesis, arrangement, workflow, mastering, music-theory
## Test 3: Page enter animation
1. Navigate to Home page
2. **Expected:** Page content fades in with a subtle upward slide (250ms)
3. Navigate to Topics page
4. **Expected:** Same fade-in animation plays
5. Navigate to Creators page, then click a creator
6. **Expected:** Both CreatorsBrowse and CreatorDetail animate on mount
7. Navigate to a technique page via search or browse
8. **Expected:** TechniquePage animates on mount
9. Navigate to a SubTopicPage
10. **Expected:** SubTopicPage animates on mount
## Test 4: catSlug shared utility correctness
1. On TopicsBrowse, verify category cards still show correct colored badges (regression check — catSlug was extracted from this file)
2. **Expected:** All category badges render identically to before the refactor
## Edge Cases
- **Missing category:** If a technique page has no topic_category, the SubTopicPage should render without a border or badge (no broken CSS var references)
- **Long category names:** Verify badges don't overflow or break layout with longer category names like "Sound Design"
- **Animation with prefers-reduced-motion:** Users with `prefers-reduced-motion: reduce` should ideally not see the animation (check if a media query is present — if not, note as minor gap)

View file

@ -0,0 +1,30 @@
{
"schemaVersion": 1,
"taskId": "T02",
"unitId": "M010/S03/T02",
"timestamp": 1774938433003,
"passed": false,
"discoverySource": "task-plan",
"checks": [
{
"command": "cd frontend",
"exitCode": 0,
"durationMs": 6,
"verdict": "pass"
},
{
"command": "npx tsc --noEmit",
"exitCode": 1,
"durationMs": 788,
"verdict": "fail"
},
{
"command": "npm run build",
"exitCode": 254,
"durationMs": 100,
"verdict": "fail"
}
],
"retryAttempt": 1,
"maxRetries": 2
}

View file

@ -1,6 +1,28 @@
# S04: Search Autocomplete & Suggestions # S04: Search Autocomplete & Suggestions
**Goal:** Lower the barrier to search — users see suggestions before committing to a query **Goal:** Search autocomplete with popular suggestions on empty focus and typeahead results on 2+ chars, working identically on both Home and SearchResults pages.
**Demo:** After this: Typing in search shows autocomplete suggestions. Empty search box shows popular search terms. **Demo:** After this: Typing in search shows autocomplete suggestions. Empty search box shows popular search terms.
## Tasks ## Tasks
- [x] **T01: Added GET /api/v1/search/suggestions endpoint returning popular techniques, topics, and creators with 5 integration tests** — Add `GET /api/v1/search/suggestions` endpoint that returns popular search terms derived from technique page titles (by view_count), popular sub-topic names (by technique_count), and creator names. Response schema: `SuggestionsResponse` with `suggestions: list[SuggestionItem]` where each item has `text: str` and `type: Literal['topic', 'technique', 'creator']`. Returns 8-12 items. Add integration test mocking DB data to verify the endpoint returns correctly shaped suggestions.
Steps:
1. Add `SuggestionItem` and `SuggestionsResponse` schemas to `backend/schemas.py`
2. Add `GET /suggestions` route to `backend/routers/search.py` that queries TechniquePage (top 4 by view_count), sub-topics from the topics router pattern (top 4 by technique_count), and Creator names (top 4 by view_count). Deduplicate and return up to 12 items.
3. Add integration test in `backend/tests/test_search.py` that seeds creators, technique pages, and verifies the suggestions endpoint returns the expected shape and types.
4. Run tests to confirm pass.
- Estimate: 30m
- Files: backend/schemas.py, backend/routers/search.py, backend/tests/test_search.py
- Verify: cd backend && python -m pytest tests/test_search.py -v -k suggestion
- [ ] **T02: Extract SearchAutocomplete component with popular suggestions on focus** — Extract the typeahead dropdown from Home.tsx into a shared `SearchAutocomplete` component. Add popular-suggestions-on-focus behavior (fetches from `/api/v1/search/suggestions` once on mount, shows when input is focused and empty). Wire the component into both Home.tsx and SearchResults.tsx.
Steps:
1. Add `fetchSuggestions` function to `frontend/src/api/public-client.ts` with types `SuggestionItem` and `SuggestionsResponse`.
2. Create `frontend/src/components/SearchAutocomplete.tsx` with props: `onSearch(query: string)`, `placeholder?: string`, `heroSize?: boolean`, `initialQuery?: string`, `autoFocus?: boolean`. Internal state: query, searchResults, popularSuggestions, showDropdown. Behavior: (a) fetch popular suggestions on mount via `fetchSuggestions`, (b) on focus with empty query show popular suggestions with a 'Popular' header, (c) on 2+ chars show debounced search results (existing pattern from Home.tsx), (d) Escape/outside-click closes dropdown, (e) Enter navigates via `onSearch` callback.
3. Refactor `Home.tsx` to use `<SearchAutocomplete heroSize onSearch={q => navigate('/search?q=' + encodeURIComponent(q))} autoFocus />` replacing the inline typeahead. Remove the extracted state/effects (query, suggestions, showDropdown, debounceRef, dropdownRef, handleInputChange, handleSubmit, handleKeyDown, and the dropdown JSX).
4. Refactor `SearchResults.tsx` to use `<SearchAutocomplete initialQuery={q} onSearch={q => navigate('/search?q=' + encodeURIComponent(q), { replace: true })} />` replacing the plain search form. Remove the localQuery state, debounceRef, handleInputChange, handleSubmit.
5. Add CSS for `.typeahead-suggestions-header` label and `.typeahead-suggestion` items (slightly different styling from search result items — simpler, just text + type badge) in `frontend/src/App.css`.
6. Run `npm run build` to verify zero TypeScript errors.
- Estimate: 1h
- Files: frontend/src/api/public-client.ts, frontend/src/components/SearchAutocomplete.tsx, frontend/src/pages/Home.tsx, frontend/src/pages/SearchResults.tsx, frontend/src/App.css
- Verify: cd frontend && npm run build

View file

@ -0,0 +1,89 @@
# S04 Research — Search Autocomplete & Suggestions
## Summary
This is **light research**. The codebase already has a working typeahead dropdown on Home.tsx (debounced 300ms, shows top 5 results after 2+ chars). The slice adds two features: (1) show popular/suggested terms when the empty search box is focused, and (2) bring the same autocomplete experience to the SearchResults page. All infrastructure exists — this is UI wiring plus one lightweight backend endpoint.
## Requirement Coverage
- **R005 (Search-First Web UI)** — already validated. This slice polishes the search interaction.
- **R015 (30-Second Retrieval Target)** — active. Autocomplete and popular suggestions directly reduce time-to-first-result.
## Recommendation
Build a lightweight `/api/v1/search/suggestions` endpoint that returns popular search terms (derived from technique page titles, topic tags, and sub-topic names ranked by technique count). Frontend changes: extract the typeahead dropdown into a shared component, add popular-suggestions-on-focus to both Home.tsx and SearchResults.tsx.
## Implementation Landscape
### What Exists
| Component | File | State |
|---|---|---|
| Search API endpoint | `backend/routers/search.py` | Working — `GET /search?q=&scope=&limit=` |
| Search service (semantic + keyword) | `backend/search_service.py` | Working — embed → Qdrant → keyword fallback |
| Search schemas | `backend/schemas.py` lines 204-224 | `SearchResultItem`, `SearchResponse` |
| API client | `frontend/src/api/public-client.ts` | `searchApi()` function |
| Home page typeahead | `frontend/src/pages/Home.tsx` | Debounced 300ms, 5 results after 2+ chars, dropdown with outside-click dismiss |
| Popular topics on home | `frontend/src/pages/Home.tsx` | Already loads top-8 sub-topics by technique_count — displayed as pills |
| SearchResults page | `frontend/src/pages/SearchResults.tsx` | Has search input but NO typeahead dropdown, no suggestions |
| Typeahead CSS | `frontend/src/App.css` lines 1060-1135 | `.typeahead-dropdown`, `.typeahead-item`, `.typeahead-see-all` all styled |
| Tests | `backend/tests/test_search.py` | Integration tests for search endpoint with mocked SearchService |
### What's Missing
1. **No suggestions endpoint** — nothing returns popular terms for an empty search box
2. **No shared typeahead component** — Home.tsx has the dropdown inline, SearchResults.tsx lacks it entirely
3. **No empty-focus behavior** — when input is focused but empty, dropdown doesn't appear
### Backend: Suggestions Endpoint
A new `GET /api/v1/search/suggestions` endpoint that returns a list of suggested search terms. These can be derived from:
- Top technique page titles (by view_count or just most recent)
- Popular sub-topic names (by technique_count — already computed in topics router)
- Creator names
No Redis caching needed for now — a simple DB query hitting TechniquePage and the canonical tags YAML is fast enough. The response is a small list (8-12 items) that changes rarely.
Schema: `{ suggestions: [{ text: string, type: "topic" | "technique" | "creator" }] }`
### Frontend: Shared Autocomplete Component
Extract the typeahead logic from Home.tsx into a reusable `SearchAutocomplete` component:
- Props: `onSearch(query)`, `placeholder`, `heroSize?: boolean`
- State: query, suggestions (search results), popularTerms, showDropdown
- Behavior:
- **Focus + empty query** → show popular suggestions (fetched once on mount)
- **2+ chars** → show search results (existing debounced behavior)
- **Escape** → close
- **Outside click** → close
- **Enter** → navigate to `/search?q=...`
- **Arrow keys** → keyboard navigation through dropdown items (nice-to-have)
Then use it in both `Home.tsx` and `SearchResults.tsx`.
### CSS
Existing typeahead CSS covers the dropdown styling. May need:
- A `.typeahead-suggestions` section for popular terms (slightly different styling — maybe a header label "Popular" or "Try searching for...")
- Keyboard-selected state (`.typeahead-item--active`) if arrow key nav is added
### Natural Seams for Task Decomposition
1. **Backend: suggestions endpoint** — new route in `backend/routers/search.py`, new schema in `schemas.py`, integration test. Independent of frontend.
2. **Frontend: extract SearchAutocomplete component** — pull typeahead out of Home.tsx into `components/SearchAutocomplete.tsx`, wire it into both Home.tsx and SearchResults.tsx, add popular-suggestions-on-focus. This depends on task 1 (needs the API client function for suggestions).
3. **Verification** — build check, manual browser verification on running app.
Tasks 1 and 2 are the core work. Task 2 is larger (component extraction + two page integrations + CSS additions).
### Risks
- **Low risk.** All patterns established. The typeahead UI exists — we're extracting it and adding an empty-state feature.
- The only non-trivial part is making arrow-key navigation work cleanly in the dropdown. If it adds too much complexity, ship without it — mouse/touch works fine.
### Verification Strategy
- `npm run build` passes with zero TypeScript errors
- Backend tests pass (existing + new suggestions endpoint test)
- Browser: focus empty search on Home → popular suggestions appear in dropdown
- Browser: type 2+ chars → search results appear
- Browser: SearchResults page has the same autocomplete behavior

View file

@ -0,0 +1,33 @@
---
estimated_steps: 6
estimated_files: 3
skills_used: []
---
# T01: Add suggestions endpoint and integration test
Add `GET /api/v1/search/suggestions` endpoint that returns popular search terms derived from technique page titles (by view_count), popular sub-topic names (by technique_count), and creator names. Response schema: `SuggestionsResponse` with `suggestions: list[SuggestionItem]` where each item has `text: str` and `type: Literal['topic', 'technique', 'creator']`. Returns 8-12 items. Add integration test mocking DB data to verify the endpoint returns correctly shaped suggestions.
Steps:
1. Add `SuggestionItem` and `SuggestionsResponse` schemas to `backend/schemas.py`
2. Add `GET /suggestions` route to `backend/routers/search.py` that queries TechniquePage (top 4 by view_count), sub-topics from the topics router pattern (top 4 by technique_count), and Creator names (top 4 by view_count). Deduplicate and return up to 12 items.
3. Add integration test in `backend/tests/test_search.py` that seeds creators, technique pages, and verifies the suggestions endpoint returns the expected shape and types.
4. Run tests to confirm pass.
## Inputs
- ``backend/routers/search.py` — existing search router to add suggestions endpoint to`
- ``backend/schemas.py` — existing schemas to add SuggestionItem and SuggestionsResponse`
- ``backend/tests/test_search.py` — existing test file with seed helpers and fixtures`
- ``backend/models.py` — TechniquePage, Creator models for the query`
- ``backend/routers/topics.py` — reference for how sub-topic aggregation is done`
## Expected Output
- ``backend/schemas.py` — SuggestionItem and SuggestionsResponse schemas added`
- ``backend/routers/search.py` — GET /suggestions endpoint added`
- ``backend/tests/test_search.py` — integration test for suggestions endpoint added`
## Verification
cd backend && python -m pytest tests/test_search.py -v -k suggestion

View file

@ -0,0 +1,79 @@
---
id: T01
parent: S04
milestone: M010
provides: []
requires: []
affects: []
key_files: ["backend/schemas.py", "backend/routers/search.py", "backend/tests/test_search.py"]
key_decisions: ["Used PostgreSQL unnest() for topic tag aggregation rather than canonical_tags.yaml", "Secondary sort by name/title for deterministic ordering when view_counts tie"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "Ran python -m pytest tests/test_search.py -v -k suggestion — all 5 tests passed in 2.05s against a real PostgreSQL test database via SSH tunnel."
completed_at: 2026-03-31T06:35:34.220Z
blocker_discovered: false
---
# T01: Added GET /api/v1/search/suggestions endpoint returning popular techniques, topics, and creators with 5 integration tests
> Added GET /api/v1/search/suggestions endpoint returning popular techniques, topics, and creators with 5 integration tests
## What Happened
---
id: T01
parent: S04
milestone: M010
key_files:
- backend/schemas.py
- backend/routers/search.py
- backend/tests/test_search.py
key_decisions:
- Used PostgreSQL unnest() for topic tag aggregation rather than canonical_tags.yaml
- Secondary sort by name/title for deterministic ordering when view_counts tie
duration: ""
verification_result: passed
completed_at: 2026-03-31T06:35:34.220Z
blocker_discovered: false
---
# T01: Added GET /api/v1/search/suggestions endpoint returning popular techniques, topics, and creators with 5 integration tests
**Added GET /api/v1/search/suggestions endpoint returning popular techniques, topics, and creators with 5 integration tests**
## What Happened
Added SuggestionItem and SuggestionsResponse Pydantic schemas to backend/schemas.py. Implemented the /suggestions endpoint in the search router with three DB queries: top 4 technique pages by view_count, top 4 topic tags via PostgreSQL unnest() aggregation, and top 4 creators (excluding hidden) by view_count. Results are deduplicated case-insensitively. Wrote 5 integration tests covering response shape, type coverage, deduplication, empty DB, and view_count ordering.
## Verification
Ran python -m pytest tests/test_search.py -v -k suggestion — all 5 tests passed in 2.05s against a real PostgreSQL test database via SSH tunnel.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `python -m pytest tests/test_search.py -v -k suggestion` | 0 | ✅ pass | 2050ms |
## Deviations
Used func.unnest() for topic tag aggregation instead of canonical_tags.yaml pattern from the topics router — more appropriate for suggestions reflecting actual DB content.
## Known Issues
None.
## Files Created/Modified
- `backend/schemas.py`
- `backend/routers/search.py`
- `backend/tests/test_search.py`
## Deviations
Used func.unnest() for topic tag aggregation instead of canonical_tags.yaml pattern from the topics router — more appropriate for suggestions reflecting actual DB content.
## Known Issues
None.

View file

@ -0,0 +1,37 @@
---
estimated_steps: 8
estimated_files: 5
skills_used: []
---
# T02: Extract SearchAutocomplete component with popular suggestions on focus
Extract the typeahead dropdown from Home.tsx into a shared `SearchAutocomplete` component. Add popular-suggestions-on-focus behavior (fetches from `/api/v1/search/suggestions` once on mount, shows when input is focused and empty). Wire the component into both Home.tsx and SearchResults.tsx.
Steps:
1. Add `fetchSuggestions` function to `frontend/src/api/public-client.ts` with types `SuggestionItem` and `SuggestionsResponse`.
2. Create `frontend/src/components/SearchAutocomplete.tsx` with props: `onSearch(query: string)`, `placeholder?: string`, `heroSize?: boolean`, `initialQuery?: string`, `autoFocus?: boolean`. Internal state: query, searchResults, popularSuggestions, showDropdown. Behavior: (a) fetch popular suggestions on mount via `fetchSuggestions`, (b) on focus with empty query show popular suggestions with a 'Popular' header, (c) on 2+ chars show debounced search results (existing pattern from Home.tsx), (d) Escape/outside-click closes dropdown, (e) Enter navigates via `onSearch` callback.
3. Refactor `Home.tsx` to use `<SearchAutocomplete heroSize onSearch={q => navigate('/search?q=' + encodeURIComponent(q))} autoFocus />` replacing the inline typeahead. Remove the extracted state/effects (query, suggestions, showDropdown, debounceRef, dropdownRef, handleInputChange, handleSubmit, handleKeyDown, and the dropdown JSX).
4. Refactor `SearchResults.tsx` to use `<SearchAutocomplete initialQuery={q} onSearch={q => navigate('/search?q=' + encodeURIComponent(q), { replace: true })} />` replacing the plain search form. Remove the localQuery state, debounceRef, handleInputChange, handleSubmit.
5. Add CSS for `.typeahead-suggestions-header` label and `.typeahead-suggestion` items (slightly different styling from search result items — simpler, just text + type badge) in `frontend/src/App.css`.
6. Run `npm run build` to verify zero TypeScript errors.
## Inputs
- ``frontend/src/pages/Home.tsx` — source of the inline typeahead to extract`
- ``frontend/src/pages/SearchResults.tsx` — needs SearchAutocomplete wired in`
- ``frontend/src/api/public-client.ts` — add fetchSuggestions client function`
- ``frontend/src/App.css` — existing typeahead CSS to extend`
- ``backend/routers/search.py` — suggestions endpoint contract (from T01)`
## Expected Output
- ``frontend/src/components/SearchAutocomplete.tsx` — new shared autocomplete component`
- ``frontend/src/api/public-client.ts` — fetchSuggestions function and types added`
- ``frontend/src/pages/Home.tsx` — refactored to use SearchAutocomplete`
- ``frontend/src/pages/SearchResults.tsx` — refactored to use SearchAutocomplete`
- ``frontend/src/App.css` — popular suggestions CSS added`
## Verification
cd frontend && npm run build

View file

@ -6,11 +6,18 @@ import logging
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import get_settings from config import get_settings
from database import get_session from database import get_session
from schemas import SearchResponse, SearchResultItem from models import Creator, TechniquePage
from schemas import (
SearchResponse,
SearchResultItem,
SuggestionItem,
SuggestionsResponse,
)
from search_service import SearchService from search_service import SearchService
logger = logging.getLogger("chrysopedia.search.router") logger = logging.getLogger("chrysopedia.search.router")
@ -44,3 +51,68 @@ async def search(
query=result["query"], query=result["query"],
fallback_used=result["fallback_used"], fallback_used=result["fallback_used"],
) )
@router.get("/suggestions", response_model=SuggestionsResponse)
async def suggestions(
db: AsyncSession = Depends(get_session),
) -> SuggestionsResponse:
"""Return popular search suggestions for autocomplete.
Combines top technique pages (by view_count), popular topic tags
(by technique count), and top creators (by view_count).
Returns 812 deduplicated items.
"""
seen: set[str] = set()
items: list[SuggestionItem] = []
def _add(text: str, type_: str) -> None:
key = text.lower()
if key not in seen:
seen.add(key)
items.append(SuggestionItem(text=text, type=type_))
# Top 4 technique pages by view_count
tp_stmt = (
select(TechniquePage.title)
.order_by(TechniquePage.view_count.desc(), TechniquePage.title)
.limit(4)
)
tp_result = await db.execute(tp_stmt)
for (title,) in tp_result.all():
_add(title, "technique")
# Top 4 topic tags by how many technique pages use them
# Unnest the topic_tags ARRAY and count occurrences
tag_unnest = (
select(
func.unnest(TechniquePage.topic_tags).label("tag"),
)
.where(TechniquePage.topic_tags.isnot(None))
.subquery()
)
tag_stmt = (
select(
tag_unnest.c.tag,
func.count().label("cnt"),
)
.group_by(tag_unnest.c.tag)
.order_by(func.count().desc(), tag_unnest.c.tag)
.limit(4)
)
tag_result = await db.execute(tag_stmt)
for tag, _cnt in tag_result.all():
_add(tag, "topic")
# Top 4 creators by view_count
cr_stmt = (
select(Creator.name)
.where(Creator.hidden.is_(False))
.order_by(Creator.view_count.desc(), Creator.name)
.limit(4)
)
cr_result = await db.execute(cr_stmt)
for (name,) in cr_result.all():
_add(name, "creator")
return SuggestionsResponse(suggestions=items)

View file

@ -8,6 +8,7 @@ from __future__ import annotations
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@ -223,6 +224,17 @@ class SearchResponse(BaseModel):
fallback_used: bool = False fallback_used: bool = False
class SuggestionItem(BaseModel):
"""A single autocomplete suggestion."""
text: str
type: Literal["topic", "technique", "creator"]
class SuggestionsResponse(BaseModel):
"""Popular search suggestions for autocomplete."""
suggestions: list[SuggestionItem] = Field(default_factory=list)
# ── Technique Page Detail ──────────────────────────────────────────────────── # ── Technique Page Detail ────────────────────────────────────────────────────
class KeyMomentSummary(BaseModel): class KeyMomentSummary(BaseModel):

View file

@ -403,3 +403,115 @@ async def test_keyword_search_key_moment_without_technique_page(db_engine):
km_results = [r for r in results if r["type"] == "key_moment"] km_results = [r for r in results if r["type"] == "key_moment"]
assert len(km_results) == 1 assert len(km_results) == 1
assert km_results[0]["technique_page_slug"] == "" assert km_results[0]["technique_page_slug"] == ""
# ── Suggestions endpoint tests ───────────────────────────────────────────────
SUGGESTIONS_URL = "/api/v1/search/suggestions"
@pytest.mark.asyncio
async def test_suggestions_returns_correct_shape(client, db_engine):
"""Suggestions endpoint returns items with text and type fields."""
await _seed_search_data(db_engine)
resp = await client.get(SUGGESTIONS_URL)
assert resp.status_code == 200
data = resp.json()
assert "suggestions" in data
assert isinstance(data["suggestions"], list)
assert len(data["suggestions"]) > 0
for item in data["suggestions"]:
assert "text" in item
assert "type" in item
assert item["type"] in ("topic", "technique", "creator")
assert len(item["text"]) > 0
@pytest.mark.asyncio
async def test_suggestions_includes_all_types(client, db_engine):
"""Suggestions should include technique, topic, and creator types."""
await _seed_search_data(db_engine)
resp = await client.get(SUGGESTIONS_URL)
assert resp.status_code == 200
data = resp.json()
types_present = {item["type"] for item in data["suggestions"]}
assert "technique" in types_present, "Expected technique suggestions"
assert "topic" in types_present, "Expected topic suggestions"
assert "creator" in types_present, "Expected creator suggestions"
@pytest.mark.asyncio
async def test_suggestions_no_duplicates(client, db_engine):
"""Suggestions should not contain duplicate texts (case-insensitive)."""
await _seed_search_data(db_engine)
resp = await client.get(SUGGESTIONS_URL)
assert resp.status_code == 200
data = resp.json()
texts_lower = [item["text"].lower() for item in data["suggestions"]]
assert len(texts_lower) == len(set(texts_lower)), "Duplicate suggestions found"
@pytest.mark.asyncio
async def test_suggestions_empty_db(client, db_engine):
"""Suggestions endpoint returns empty list on empty database."""
resp = await client.get(SUGGESTIONS_URL)
assert resp.status_code == 200
data = resp.json()
assert data["suggestions"] == []
@pytest.mark.asyncio
async def test_suggestions_respects_view_count_ordering(client, db_engine):
"""Higher view_count technique pages should appear first among techniques."""
session_factory = async_sessionmaker(
db_engine, class_=AsyncSession, expire_on_commit=False
)
async with session_factory() as session:
creator = Creator(
name="Test Creator",
slug="test-creator",
genres=["Electronic"],
folder_name="TestCreator",
view_count=10,
)
session.add(creator)
await session.flush()
tp_low = TechniquePage(
creator_id=creator.id,
title="Low Views Page",
slug="low-views-page",
topic_category="Sound design",
topic_tags=["bass"],
view_count=5,
)
tp_high = TechniquePage(
creator_id=creator.id,
title="High Views Page",
slug="high-views-page",
topic_category="Synthesis",
topic_tags=["pads"],
view_count=100,
)
session.add_all([tp_low, tp_high])
await session.commit()
resp = await client.get(SUGGESTIONS_URL)
assert resp.status_code == 200
data = resp.json()
technique_items = [
item for item in data["suggestions"] if item["type"] == "technique"
]
assert len(technique_items) >= 2
# High Views Page should come before Low Views Page
titles = [item["text"] for item in technique_items]
assert titles.index("High Views Page") < titles.index("Low Views Page")