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:
parent
ec7e07c705
commit
1254e173d4
12 changed files with 631 additions and 3 deletions
|
|
@ -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. |
|
||||||
|
|
|
||||||
90
.gsd/milestones/M010/slices/S03/S03-SUMMARY.md
Normal file
90
.gsd/milestones/M010/slices/S03/S03-SUMMARY.md
Normal 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
|
||||||
52
.gsd/milestones/M010/slices/S03/S03-UAT.md
Normal file
52
.gsd/milestones/M010/slices/S03/S03-UAT.md
Normal 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)
|
||||||
30
.gsd/milestones/M010/slices/S03/tasks/T02-VERIFY.json
Normal file
30
.gsd/milestones/M010/slices/S03/tasks/T02-VERIFY.json
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
89
.gsd/milestones/M010/slices/S04/S04-RESEARCH.md
Normal file
89
.gsd/milestones/M010/slices/S04/S04-RESEARCH.md
Normal 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
|
||||||
33
.gsd/milestones/M010/slices/S04/tasks/T01-PLAN.md
Normal file
33
.gsd/milestones/M010/slices/S04/tasks/T01-PLAN.md
Normal 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
|
||||||
79
.gsd/milestones/M010/slices/S04/tasks/T01-SUMMARY.md
Normal file
79
.gsd/milestones/M010/slices/S04/tasks/T01-SUMMARY.md
Normal 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.
|
||||||
37
.gsd/milestones/M010/slices/S04/tasks/T02-PLAN.md
Normal file
37
.gsd/milestones/M010/slices/S04/tasks/T02-PLAN.md
Normal 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
|
||||||
|
|
@ -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 8–12 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)
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue