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 |
| 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. |

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
**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.
## 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 fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from config import get_settings
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
logger = logging.getLogger("chrysopedia.search.router")
@ -44,3 +51,68 @@ async def search(
query=result["query"],
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
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
@ -223,6 +224,17 @@ class SearchResponse(BaseModel):
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 ────────────────────────────────────────────────────
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"]
assert len(km_results) == 1
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")