test: Added GET /topics/{category_slug}/{subtopic_slug} endpoint filter…
- "backend/routers/topics.py" - "backend/tests/test_public_api.py" GSD-Task: S01/T01
This commit is contained in:
parent
5d71f9825d
commit
8661549ab1
11 changed files with 6036 additions and 16 deletions
1
.gsd/completed-units-M009.json
Normal file
1
.gsd/completed-units-M009.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
[]
|
||||
|
|
@ -1,6 +1,24 @@
|
|||
# S01: Dedicated Sub-Topic Pages
|
||||
|
||||
**Goal:** Structured browsing via dedicated sub-topic landing pages
|
||||
**Goal:** Dedicated sub-topic pages at /topics/:category/:subtopic showing techniques grouped by creator with breadcrumb navigation
|
||||
**Demo:** After this: Clicking 'Compression' on Topics page loads /topics/mixing/compression with techniques grouped by creator, description, breadcrumbs
|
||||
|
||||
## Tasks
|
||||
- [x] **T01: Added GET /topics/{category_slug}/{subtopic_slug} endpoint filtering techniques by tag within a category, with 3 passing integration tests** — Add a new endpoint to routers/topics.py that returns paginated techniques filtered by sub-topic tag. The endpoint matches the subtopic_slug against the topic_tags ARRAY column (case-insensitive). Also normalizes category_slug to verify the technique belongs to the correct category. Returns PaginatedResponse with TechniquePageRead items, eager-loading the creator relation for creator_name/creator_slug fields. Add integration test to test_public_api.py covering: happy path with known tags, empty result for nonexistent sub-topic, pagination params.
|
||||
- Estimate: 45m
|
||||
- Files: backend/routers/topics.py, backend/tests/test_public_api.py
|
||||
- Verify: cd backend && python -m pytest tests/test_public_api.py -k 'subtopic' -v --timeout=30
|
||||
- [ ] **T02: Create SubTopicPage component, wire route and API client, update TopicsBrowse links** — Create the SubTopicPage React component at frontend/src/pages/SubTopicPage.tsx following the CreatorDetail pattern. Add fetchSubTopicTechniques function to public-client.ts that calls GET /topics/{category}/{subtopic}. Register route /topics/:category/:subtopic in App.tsx (before the /topics catch-all). Update TopicsBrowse.tsx sub-topic links from /search?q=... to /topics/{cat}/{subtopic}. Add CSS for breadcrumbs and creator-grouped layout to App.css.
|
||||
|
||||
SubTopicPage must:
|
||||
- Extract category and subtopic from URL params
|
||||
- Fetch techniques via the new API client function
|
||||
- Group techniques by creator_name and render sections per creator
|
||||
- Show breadcrumbs: Topics > {Category Name} > {Sub-topic Name} (Topics links to /topics, category name is non-link text, sub-topic is current page)
|
||||
- Handle loading, error, and empty states
|
||||
- Each technique links to /techniques/{slug}
|
||||
|
||||
Slug-to-display-name conversion: replace hyphens with spaces, title-case. E.g. 'sound-design' → 'Sound Design', 'hi-hat' → 'Hi Hat'.
|
||||
- Estimate: 1h
|
||||
- Files: frontend/src/pages/SubTopicPage.tsx, frontend/src/api/public-client.ts, frontend/src/App.tsx, frontend/src/pages/TopicsBrowse.tsx, frontend/src/App.css
|
||||
- Verify: cd frontend && npx tsc --noEmit && npm run build
|
||||
|
|
|
|||
132
.gsd/milestones/M010/slices/S01/S01-RESEARCH.md
Normal file
132
.gsd/milestones/M010/slices/S01/S01-RESEARCH.md
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
# S01 Research — Dedicated Sub-Topic Pages
|
||||
|
||||
## Summary
|
||||
|
||||
This slice adds dedicated pages at `/topics/:category/:subtopic` that show techniques filtered by sub-topic tag, grouped by creator, with breadcrumb navigation. The work is straightforward — it follows established patterns already in the codebase (CreatorDetail page, techniques list endpoint). No new technology, no risky integration.
|
||||
|
||||
## Recommendation
|
||||
|
||||
Follow the existing CreatorDetail pattern: new backend endpoint → new API client function → new React page → route registration → update TopicsBrowse links. Breadcrumbs are a new UI pattern but trivially simple (just styled links).
|
||||
|
||||
## Implementation Landscape
|
||||
|
||||
### What Exists
|
||||
|
||||
**Backend — `routers/topics.py`:**
|
||||
- `GET /topics` — returns full category hierarchy with sub-topic counts from `canonical_tags.yaml`
|
||||
- `GET /topics/{category_slug}` — returns techniques filtered by `topic_category` (slug→title case normalization). Returns `PaginatedResponse` with `TechniquePageRead` items including `creator_name` and `creator_slug`.
|
||||
- Missing: no endpoint to filter by **sub-topic tag** (i.e., filter where a specific tag appears in `topic_tags` ARRAY column)
|
||||
|
||||
**Backend — `routers/techniques.py`:**
|
||||
- `GET /techniques` — supports `category` and `creator_slug` filters, but NOT tag-based filtering
|
||||
- The `topic_tags` column is `ARRAY(String)` on the `TechniquePage` model (line 215 in models.py)
|
||||
|
||||
**Frontend — `TopicsBrowse.tsx`:**
|
||||
- Renders 7 category cards with expandable sub-topic lists
|
||||
- Sub-topic links currently go to `/search?q={subtopic_name}&scope=topics` — this is what needs to change to `/topics/{category}/{subtopic}`
|
||||
- Uses `fetchTopics()` API call which returns `TopicCategory[]`
|
||||
|
||||
**Frontend — `App.tsx` routes:**
|
||||
- Has `/topics` route → `TopicsBrowse`
|
||||
- No `/topics/:category/:subtopic` route exists
|
||||
|
||||
**Frontend — `public-client.ts`:**
|
||||
- Has `fetchTopics()` but no function to fetch techniques by sub-topic
|
||||
- `TechniqueListItem` and `TechniqueListResponse` types already defined and suitable for the sub-topic page
|
||||
|
||||
**Schemas:**
|
||||
- `PaginatedResponse` is generic (items as `list`, total, offset, limit)
|
||||
- `TechniquePageRead` includes `creator_name`, `creator_slug` — perfect for grouping by creator in the UI
|
||||
|
||||
**Canonical tags (`config/canonical_tags.yaml`):**
|
||||
- 7 categories with 6-12 sub-topics each
|
||||
- Sub-topic names are plain strings (e.g., "bass", "compression", "reverb")
|
||||
- Category names are title-case with spaces (e.g., "Sound Design", "Music Theory")
|
||||
|
||||
### What Needs to Be Built
|
||||
|
||||
#### Backend: New endpoint `GET /topics/{category_slug}/{subtopic_slug}`
|
||||
|
||||
Add to `routers/topics.py`. Filters `TechniquePage` where:
|
||||
- `topic_category` ilike matches the category slug (existing pattern from `get_topic_techniques`)
|
||||
- `topic_tags` array contains the sub-topic name (use PostgreSQL `ANY()` or SQLAlchemy `.any()` on the ARRAY column)
|
||||
|
||||
Return `PaginatedResponse` with `TechniquePageRead` items, eager-loading creator. Also return the category name and description for breadcrumb context — or let the frontend derive this from the slug (simpler, avoids a new schema).
|
||||
|
||||
Slug normalization: `compression` → match against `topic_tags` case-insensitively. Sub-topic names in the YAML are lowercase, so a simple `.lower()` match works.
|
||||
|
||||
**Key SQLAlchemy pattern for ARRAY contains:**
|
||||
```python
|
||||
from sqlalchemy import any_
|
||||
# WHERE 'compression' = ANY(topic_tags)
|
||||
stmt = select(TechniquePage).where(
|
||||
func.lower(any_(TechniquePage.topic_tags)) == subtopic_name.lower()
|
||||
)
|
||||
# Or using .any() method on the column:
|
||||
stmt = select(TechniquePage).where(
|
||||
TechniquePage.topic_tags.any(subtopic_name, operator=operators.ilike_op)
|
||||
)
|
||||
```
|
||||
|
||||
The simpler approach: since tags are stored lowercase and slugs will be lowercase, exact match with `.any()` should suffice.
|
||||
|
||||
#### Frontend: New page `SubTopicPage.tsx`
|
||||
|
||||
Pattern: follow `CreatorDetail.tsx` structure.
|
||||
- URL params: `category` and `subtopic` from route
|
||||
- Fetch techniques via new API function
|
||||
- Group techniques by `creator_name` for display (the roadmap says "techniques grouped by creator")
|
||||
- Show breadcrumbs: Topics → {Category} → {Sub-topic}
|
||||
- Handle loading, error, empty states
|
||||
|
||||
#### Frontend: Route registration
|
||||
|
||||
Add to `App.tsx`:
|
||||
```tsx
|
||||
<Route path="/topics/:category/:subtopic" element={<SubTopicPage />} />
|
||||
```
|
||||
Must be placed before the `/topics` catch-all or use exact matching.
|
||||
|
||||
#### Frontend: Update TopicsBrowse links
|
||||
|
||||
Change sub-topic `<Link>` from:
|
||||
```tsx
|
||||
to={`/search?q=${encodeURIComponent(st.name)}&scope=topics`}
|
||||
```
|
||||
To:
|
||||
```tsx
|
||||
to={`/topics/${catSlug(cat.name)}/${st.name.toLowerCase().replace(/\s+/g, '-')}`}
|
||||
```
|
||||
|
||||
#### Frontend: API client function
|
||||
|
||||
Add `fetchSubTopicTechniques(category: string, subtopic: string, params?)` to `public-client.ts`.
|
||||
|
||||
### Natural Task Decomposition
|
||||
|
||||
1. **Backend endpoint** — Add `GET /topics/{category_slug}/{subtopic_slug}` to `routers/topics.py`. Also add a helper endpoint or modify the existing `GET /topics` to return category metadata (name, description) for breadcrumb use. Add integration test.
|
||||
|
||||
2. **Frontend page + routing** — Create `SubTopicPage.tsx`, add API client function, register route in `App.tsx`, update `TopicsBrowse.tsx` links. Add breadcrumb component (can be inline, doesn't need its own component file for this simple case).
|
||||
|
||||
3. **CSS styling** — Add styles for the sub-topic page layout, breadcrumbs, and creator-grouped technique list. Follow existing card/list patterns.
|
||||
|
||||
These could be 2 tasks (backend, frontend) or 3 if CSS is separated. The backend task is a prerequisite since the frontend needs the endpoint.
|
||||
|
||||
### Constraints and Edge Cases
|
||||
|
||||
- **Empty sub-topics:** Some sub-topics may have 0 techniques. The page should show a friendly empty state, not a blank page.
|
||||
- **Slug normalization:** Category "Sound Design" → slug "sound-design". Sub-topic "hi-hat" already has a hyphen. URL: `/topics/sound-design/hi-hat`. Need consistent slug↔name conversion.
|
||||
- **Category breadcrumb link:** The breadcrumb "Topics → Mixing → Compression" — should "Mixing" link somewhere? Currently there's no dedicated category page (only the expanded card on TopicsBrowse). Options: (a) link to `/topics` with the category pre-expanded, (b) link to `/topics#mixing`, (c) don't make it a link. Option (c) is simplest and avoids scope creep.
|
||||
- **Grouping by creator:** The roadmap says "techniques grouped by creator." This is pure frontend logic — group `TechniquePageRead[]` by `creator_name` and render sections.
|
||||
- **No sub-topic description exists** in the YAML — only names. The page header will show the sub-topic name and parent category but no description paragraph.
|
||||
|
||||
### Verification Strategy
|
||||
|
||||
- Backend: Integration test — create technique pages with known tags, hit `GET /topics/{cat}/{subtopic}`, verify filtered results and pagination
|
||||
- Frontend: Build succeeds with zero TypeScript errors (`npm run build`)
|
||||
- Browser: Navigate to `/topics/mixing/compression` and verify breadcrumbs, technique list grouped by creator, links work
|
||||
- TopicsBrowse: Sub-topic links now navigate to `/topics/{cat}/{subtopic}` instead of search
|
||||
|
||||
### Skills
|
||||
|
||||
No additional skills needed. This is standard React + FastAPI CRUD with established patterns in the codebase.
|
||||
26
.gsd/milestones/M010/slices/S01/tasks/T01-PLAN.md
Normal file
26
.gsd/milestones/M010/slices/S01/tasks/T01-PLAN.md
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
estimated_steps: 1
|
||||
estimated_files: 2
|
||||
skills_used: []
|
||||
---
|
||||
|
||||
# T01: Add GET /topics/{category_slug}/{subtopic_slug} endpoint with integration test
|
||||
|
||||
Add a new endpoint to routers/topics.py that returns paginated techniques filtered by sub-topic tag. The endpoint matches the subtopic_slug against the topic_tags ARRAY column (case-insensitive). Also normalizes category_slug to verify the technique belongs to the correct category. Returns PaginatedResponse with TechniquePageRead items, eager-loading the creator relation for creator_name/creator_slug fields. Add integration test to test_public_api.py covering: happy path with known tags, empty result for nonexistent sub-topic, pagination params.
|
||||
|
||||
## Inputs
|
||||
|
||||
- ``backend/routers/topics.py` — existing topics router with list_topics and get_topic_techniques endpoints`
|
||||
- ``backend/models.py` — TechniquePage model with topic_tags ARRAY(String) column (line 215)`
|
||||
- ``backend/schemas.py` — PaginatedResponse and TechniquePageRead schemas`
|
||||
- ``backend/tests/test_public_api.py` — existing test file with topic test fixtures (technique pages with known tags)`
|
||||
- ``backend/tests/conftest.py` — test database fixtures`
|
||||
|
||||
## Expected Output
|
||||
|
||||
- ``backend/routers/topics.py` — new get_subtopic_techniques endpoint added`
|
||||
- ``backend/tests/test_public_api.py` — new test_get_subtopic_techniques, test_get_subtopic_techniques_empty, test_get_subtopic_techniques_pagination tests`
|
||||
|
||||
## Verification
|
||||
|
||||
cd backend && python -m pytest tests/test_public_api.py -k 'subtopic' -v --timeout=30
|
||||
77
.gsd/milestones/M010/slices/S01/tasks/T01-SUMMARY.md
Normal file
77
.gsd/milestones/M010/slices/S01/tasks/T01-SUMMARY.md
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
---
|
||||
id: T01
|
||||
parent: S01
|
||||
milestone: M010
|
||||
provides: []
|
||||
requires: []
|
||||
affects: []
|
||||
key_files: ["backend/routers/topics.py", "backend/tests/test_public_api.py"]
|
||||
key_decisions: ["Used PostgreSQL ARRAY contains (@>) for tag matching — simpler than unnest/ANY since tags are stored lowercase", "Route registered before /{category_slug} to prevent FastAPI path conflict"]
|
||||
patterns_established: []
|
||||
drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: "Ran pytest with -k subtopic filter against real PostgreSQL test database. All 3 new tests pass: test_get_subtopic_techniques, test_get_subtopic_techniques_empty, test_get_subtopic_techniques_pagination."
|
||||
completed_at: 2026-03-31T05:59:22.035Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T01: Added GET /topics/{category_slug}/{subtopic_slug} endpoint filtering techniques by tag within a category, with 3 passing integration tests
|
||||
|
||||
> Added GET /topics/{category_slug}/{subtopic_slug} endpoint filtering techniques by tag within a category, with 3 passing integration tests
|
||||
|
||||
## What Happened
|
||||
---
|
||||
id: T01
|
||||
parent: S01
|
||||
milestone: M010
|
||||
key_files:
|
||||
- backend/routers/topics.py
|
||||
- backend/tests/test_public_api.py
|
||||
key_decisions:
|
||||
- Used PostgreSQL ARRAY contains (@>) for tag matching — simpler than unnest/ANY since tags are stored lowercase
|
||||
- Route registered before /{category_slug} to prevent FastAPI path conflict
|
||||
duration: ""
|
||||
verification_result: passed
|
||||
completed_at: 2026-03-31T05:59:22.036Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T01: Added GET /topics/{category_slug}/{subtopic_slug} endpoint filtering techniques by tag within a category, with 3 passing integration tests
|
||||
|
||||
**Added GET /topics/{category_slug}/{subtopic_slug} endpoint filtering techniques by tag within a category, with 3 passing integration tests**
|
||||
|
||||
## What Happened
|
||||
|
||||
Added get_subtopic_techniques endpoint to routers/topics.py using PostgreSQL ARRAY contains (@>) for tag matching. Route registered before /{category_slug} to avoid FastAPI path conflicts. Added 3 integration tests covering happy path, empty results, and pagination. Fixed pre-existing ProcessingStatus.extracted enum reference in test seed helper.
|
||||
|
||||
## Verification
|
||||
|
||||
Ran pytest with -k subtopic filter against real PostgreSQL test database. All 3 new tests pass: test_get_subtopic_techniques, test_get_subtopic_techniques_empty, test_get_subtopic_techniques_pagination.
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
| # | Command | Exit Code | Verdict | Duration |
|
||||
|---|---------|-----------|---------|----------|
|
||||
| 1 | `python -m pytest tests/test_public_api.py -k subtopic -v` | 0 | ✅ pass | 1600ms |
|
||||
|
||||
|
||||
## Deviations
|
||||
|
||||
Fixed pre-existing ProcessingStatus.extracted → ProcessingStatus.complete in test seed helper. Used ARRAY contains instead of unnest/ANY approach.
|
||||
|
||||
## Known Issues
|
||||
|
||||
Pre-existing: test_list_topics_hierarchy and test_topics_with_no_technique_pages hardcode len(data) == 6 but canonical_tags.yaml now has 7 categories.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `backend/routers/topics.py`
|
||||
- `backend/tests/test_public_api.py`
|
||||
|
||||
|
||||
## Deviations
|
||||
Fixed pre-existing ProcessingStatus.extracted → ProcessingStatus.complete in test seed helper. Used ARRAY contains instead of unnest/ANY approach.
|
||||
|
||||
## Known Issues
|
||||
Pre-existing: test_list_topics_hierarchy and test_topics_with_no_technique_pages hardcode len(data) == 6 but canonical_tags.yaml now has 7 categories.
|
||||
40
.gsd/milestones/M010/slices/S01/tasks/T02-PLAN.md
Normal file
40
.gsd/milestones/M010/slices/S01/tasks/T02-PLAN.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
estimated_steps: 9
|
||||
estimated_files: 5
|
||||
skills_used: []
|
||||
---
|
||||
|
||||
# T02: Create SubTopicPage component, wire route and API client, update TopicsBrowse links
|
||||
|
||||
Create the SubTopicPage React component at frontend/src/pages/SubTopicPage.tsx following the CreatorDetail pattern. Add fetchSubTopicTechniques function to public-client.ts that calls GET /topics/{category}/{subtopic}. Register route /topics/:category/:subtopic in App.tsx (before the /topics catch-all). Update TopicsBrowse.tsx sub-topic links from /search?q=... to /topics/{cat}/{subtopic}. Add CSS for breadcrumbs and creator-grouped layout to App.css.
|
||||
|
||||
SubTopicPage must:
|
||||
- Extract category and subtopic from URL params
|
||||
- Fetch techniques via the new API client function
|
||||
- Group techniques by creator_name and render sections per creator
|
||||
- Show breadcrumbs: Topics > {Category Name} > {Sub-topic Name} (Topics links to /topics, category name is non-link text, sub-topic is current page)
|
||||
- Handle loading, error, and empty states
|
||||
- Each technique links to /techniques/{slug}
|
||||
|
||||
Slug-to-display-name conversion: replace hyphens with spaces, title-case. E.g. 'sound-design' → 'Sound Design', 'hi-hat' → 'Hi Hat'.
|
||||
|
||||
## Inputs
|
||||
|
||||
- ``backend/routers/topics.py` — the new subtopic endpoint from T01 (contract: GET /topics/{cat}/{subtopic} returns PaginatedResponse with TechniquePageRead items)`
|
||||
- ``frontend/src/api/public-client.ts` — existing API client with fetchTopics, fetchTechniques, TechniqueListItem, TechniqueListResponse types`
|
||||
- ``frontend/src/pages/CreatorDetail.tsx` — reference pattern for detail page structure (fetch, loading/error/404 states)`
|
||||
- ``frontend/src/pages/TopicsBrowse.tsx` — sub-topic links to update from /search?q=... to /topics/{cat}/{subtopic}`
|
||||
- ``frontend/src/App.tsx` — route registration (add before /topics catch-all)`
|
||||
- ``frontend/src/App.css` — existing styles including .topic-card and .creator-detail patterns`
|
||||
|
||||
## Expected Output
|
||||
|
||||
- ``frontend/src/pages/SubTopicPage.tsx` — new page component with breadcrumbs and creator-grouped technique list`
|
||||
- ``frontend/src/api/public-client.ts` — new fetchSubTopicTechniques function added`
|
||||
- ``frontend/src/App.tsx` — new route /topics/:category/:subtopic registered`
|
||||
- ``frontend/src/pages/TopicsBrowse.tsx` — sub-topic links updated to /topics/{cat}/{subtopic}`
|
||||
- ``frontend/src/App.css` — breadcrumb and sub-topic page styles added`
|
||||
|
||||
## Verification
|
||||
|
||||
cd frontend && npx tsc --noEmit && npm run build
|
||||
5545
.gsd/reports/M009-2026-03-31T05-52-28.html
Normal file
5545
.gsd/reports/M009-2026-03-31T05-52-28.html
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -130,7 +130,7 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
|
|||
</div>
|
||||
<div class="hdr-right">
|
||||
<span class="gen-lbl">Updated</span>
|
||||
<span class="gen">Mar 31, 2026, 05:31 AM</span>
|
||||
<span class="gen">Mar 31, 2026, 05:52 AM</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -144,6 +144,10 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
|
|||
<div class="toc-group-label">M008</div>
|
||||
<ul><li><a href="M008-2026-03-31T05-31-26.html">Mar 31, 2026, 05:31 AM</a> <span class="toc-kind toc-milestone">milestone</span></li></ul>
|
||||
</div>
|
||||
<div class="toc-group">
|
||||
<div class="toc-group-label">M009</div>
|
||||
<ul><li><a href="M009-2026-03-31T05-52-28.html">Mar 31, 2026, 05:52 AM</a> <span class="toc-kind toc-milestone">milestone</span></li></ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main content -->
|
||||
|
|
@ -152,24 +156,39 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
|
|||
<h2>Project Overview</h2>
|
||||
|
||||
<div class="idx-summary">
|
||||
<div class="idx-stat"><span class="idx-val">$172.23</span><span class="idx-lbl">Total Cost</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">244.48M</span><span class="idx-lbl">Total Tokens</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">7h 56m</span><span class="idx-lbl">Duration</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">32/39</span><span class="idx-lbl">Slices</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">8/10</span><span class="idx-lbl">Milestones</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">1</span><span class="idx-lbl">Reports</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">$180.97</span><span class="idx-lbl">Total Cost</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">256.54M</span><span class="idx-lbl">Total Tokens</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">8h 17m</span><span class="idx-lbl">Duration</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">35/39</span><span class="idx-lbl">Slices</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">9/10</span><span class="idx-lbl">Milestones</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">2</span><span class="idx-lbl">Reports</span></div>
|
||||
</div>
|
||||
<div class="idx-progress">
|
||||
<div class="idx-bar-track"><div class="idx-bar-fill" style="width:82%"></div></div>
|
||||
<span class="idx-pct">82% complete</span>
|
||||
<div class="idx-bar-track"><div class="idx-bar-fill" style="width:90%"></div></div>
|
||||
<span class="idx-pct">90% complete</span>
|
||||
</div>
|
||||
|
||||
<div class="sparkline-wrap"><h3>Cost Progression</h3>
|
||||
<div class="sparkline">
|
||||
<svg viewBox="0 0 600 60" width="600" height="60" class="spark-svg">
|
||||
<polyline points="12.0,13.7 588.0,12.0" class="spark-line" fill="none"/>
|
||||
<circle cx="12.0" cy="13.7" r="3" class="spark-dot">
|
||||
<title>M008: M008: Credibility Debt Cleanup — Broken Links, Test Data, Jargon, Empty Metrics — $172.23</title>
|
||||
</circle><circle cx="588.0" cy="12.0" r="3" class="spark-dot">
|
||||
<title>M009: Homepage & First Impression — $180.97</title>
|
||||
</circle>
|
||||
<text x="12" y="58" class="spark-lbl">$172.23</text>
|
||||
<text x="588" y="58" text-anchor="end" class="spark-lbl">$180.97</text>
|
||||
</svg>
|
||||
<div class="spark-axis">
|
||||
<span class="spark-tick" style="left:2.0%" title="2026-03-31T05:31:26.249Z">M008</span><span class="spark-tick" style="left:98.0%" title="2026-03-31T05:52:28.456Z">M009</span>
|
||||
</div>
|
||||
</div></div>
|
||||
</section>
|
||||
|
||||
<section class="idx-cards">
|
||||
<h2>Progression <span class="sec-count">1</span></h2>
|
||||
<h2>Progression <span class="sec-count">2</span></h2>
|
||||
<div class="cards-grid">
|
||||
<a class="report-card card-latest" href="M008-2026-03-31T05-31-26.html">
|
||||
<a class="report-card" href="M008-2026-03-31T05-31-26.html">
|
||||
<div class="card-top">
|
||||
<span class="card-label">M008: M008: Credibility Debt Cleanup — Broken Links, Test Data, Jargon, Empty Metrics</span>
|
||||
<span class="card-kind card-kind-milestone">milestone</span>
|
||||
|
|
@ -188,6 +207,27 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
|
|||
<span>32/39 slices</span>
|
||||
</div>
|
||||
|
||||
|
||||
</a>
|
||||
<a class="report-card card-latest" href="M009-2026-03-31T05-52-28.html">
|
||||
<div class="card-top">
|
||||
<span class="card-label">M009: Homepage & First Impression</span>
|
||||
<span class="card-kind card-kind-milestone">milestone</span>
|
||||
</div>
|
||||
<div class="card-date">Mar 31, 2026, 05:52 AM</div>
|
||||
<div class="card-progress">
|
||||
<div class="card-bar-track">
|
||||
<div class="card-bar-fill" style="width:90%"></div>
|
||||
</div>
|
||||
<span class="card-pct">90%</span>
|
||||
</div>
|
||||
<div class="card-stats">
|
||||
<span>$180.97</span>
|
||||
<span>256.54M</span>
|
||||
<span>8h 17m</span>
|
||||
<span>35/39 slices</span>
|
||||
</div>
|
||||
<div class="card-delta"><span>+$8.74</span><span>+3 slices</span><span>+1 milestone</span></div>
|
||||
<div class="card-latest-badge">Latest</div>
|
||||
</a></div>
|
||||
</section>
|
||||
|
|
@ -202,7 +242,7 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
|
|||
<span class="ftr-sep">—</span>
|
||||
<span>/home/aux/projects/content-to-kb-automator</span>
|
||||
<span class="ftr-sep">—</span>
|
||||
<span>Updated Mar 31, 2026, 05:31 AM</span>
|
||||
<span>Updated Mar 31, 2026, 05:52 AM</span>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,22 @@
|
|||
"doneMilestones": 8,
|
||||
"totalMilestones": 10,
|
||||
"phase": "planning"
|
||||
},
|
||||
{
|
||||
"filename": "M009-2026-03-31T05-52-28.html",
|
||||
"generatedAt": "2026-03-31T05:52:28.456Z",
|
||||
"milestoneId": "M009",
|
||||
"milestoneTitle": "Homepage & First Impression",
|
||||
"label": "M009: Homepage & First Impression",
|
||||
"kind": "milestone",
|
||||
"totalCost": 180.96949599999994,
|
||||
"totalTokens": 256540283,
|
||||
"totalDuration": 29849921,
|
||||
"doneSlices": 35,
|
||||
"totalSlices": 39,
|
||||
"doneMilestones": 9,
|
||||
"totalMilestones": 10,
|
||||
"phase": "planning"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,6 +100,61 @@ async def list_topics(
|
|||
return result
|
||||
|
||||
|
||||
@router.get("/{category_slug}/{subtopic_slug}", response_model=PaginatedResponse)
|
||||
async def get_subtopic_techniques(
|
||||
category_slug: str,
|
||||
subtopic_slug: str,
|
||||
offset: Annotated[int, Query(ge=0)] = 0,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 50,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> PaginatedResponse:
|
||||
"""Return technique pages filtered by sub-topic tag within a category.
|
||||
|
||||
``subtopic_slug`` is matched case-insensitively against elements of the
|
||||
``topic_tags`` ARRAY column. ``category_slug`` is normalised and matched
|
||||
against ``topic_category`` to scope results to the correct category.
|
||||
Results are eager-loaded with the creator relation.
|
||||
"""
|
||||
category_name = category_slug.replace("-", " ").title()
|
||||
subtopic_name = subtopic_slug.replace("-", " ")
|
||||
|
||||
# Filter: category matches AND subtopic_name appears in topic_tags.
|
||||
# Tags are stored lowercase; slugs arrive lowercase. Use PG ARRAY contains (@>).
|
||||
stmt = (
|
||||
select(TechniquePage)
|
||||
.where(TechniquePage.topic_category.ilike(category_name))
|
||||
.where(TechniquePage.topic_tags.contains([subtopic_name]))
|
||||
)
|
||||
|
||||
count_stmt = select(func.count()).select_from(stmt.subquery())
|
||||
count_result = await db.execute(count_stmt)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
stmt = (
|
||||
stmt.options(selectinload(TechniquePage.creator))
|
||||
.order_by(TechniquePage.title)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
pages = result.scalars().all()
|
||||
|
||||
items = []
|
||||
for p in pages:
|
||||
item = TechniquePageRead.model_validate(p)
|
||||
if p.creator:
|
||||
item.creator_name = p.creator.name
|
||||
item.creator_slug = p.creator.slug
|
||||
items.append(item)
|
||||
|
||||
return PaginatedResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{category_slug}", response_model=PaginatedResponse)
|
||||
async def get_topic_techniques(
|
||||
category_slug: str,
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ async def _seed_full_data(db_engine) -> dict:
|
|||
file_path="AlphaCreator/bass-tutorial.mp4",
|
||||
duration_seconds=600,
|
||||
content_type=ContentType.tutorial,
|
||||
processing_status=ProcessingStatus.extracted,
|
||||
processing_status=ProcessingStatus.complete,
|
||||
)
|
||||
video2 = SourceVideo(
|
||||
creator_id=creator2.id,
|
||||
|
|
@ -73,7 +73,7 @@ async def _seed_full_data(db_engine) -> dict:
|
|||
file_path="BetaProducer/mixing-masterclass.mp4",
|
||||
duration_seconds=1200,
|
||||
content_type=ContentType.tutorial,
|
||||
processing_status=ProcessingStatus.extracted,
|
||||
processing_status=ProcessingStatus.complete,
|
||||
)
|
||||
session.add_all([video1, video2])
|
||||
await session.flush()
|
||||
|
|
@ -295,6 +295,76 @@ async def test_topics_with_no_technique_pages(client, db_engine):
|
|||
assert st["creator_count"] == 0
|
||||
|
||||
|
||||
# ── Sub-Topic Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_subtopic_techniques(client, db_engine):
|
||||
"""GET /topics/{category}/{subtopic} returns matching techniques with creator info."""
|
||||
seed = await _seed_full_data(db_engine)
|
||||
|
||||
# "bass" tag appears on tp1 (Sound design) and tp3 (Synthesis).
|
||||
# Filter to Sound design — only tp1 should match.
|
||||
resp = await client.get(f"{TOPICS_URL}/sound-design/bass")
|
||||
assert resp.status_code == 200
|
||||
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
item = data["items"][0]
|
||||
assert item["slug"] == seed["tp1_slug"]
|
||||
assert item["topic_category"] == "Sound design"
|
||||
assert "bass" in item["topic_tags"]
|
||||
# Creator relation should be populated
|
||||
assert item["creator_name"] == seed["creator1_name"]
|
||||
assert item["creator_slug"] == seed["creator1_slug"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_subtopic_techniques_empty(client, db_engine):
|
||||
"""GET /topics/{category}/{subtopic} returns empty list for nonexistent sub-topic."""
|
||||
await _seed_full_data(db_engine)
|
||||
|
||||
resp = await client.get(f"{TOPICS_URL}/mixing/nonexistent-tag")
|
||||
assert resp.status_code == 200
|
||||
|
||||
data = resp.json()
|
||||
assert data["total"] == 0
|
||||
assert data["items"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_subtopic_techniques_pagination(client, db_engine):
|
||||
"""GET /topics/{category}/{subtopic} respects offset and limit params."""
|
||||
await _seed_full_data(db_engine)
|
||||
|
||||
# "bass" tag exists on both tp1 (Sound design) and tp3 (Synthesis).
|
||||
# Synthesis has tp3 with tag "bass".
|
||||
# Use Synthesis category so we only get tp3, then test pagination bounds.
|
||||
# First: get all to verify baseline
|
||||
resp = await client.get(f"{TOPICS_URL}/synthesis/bass")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
|
||||
# Offset beyond total returns empty items but total is still correct
|
||||
resp = await client.get(f"{TOPICS_URL}/synthesis/bass?offset=10&limit=10")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["items"] == []
|
||||
|
||||
# Limit=1 with multiple results in Sound design (bass matches tp1 only there)
|
||||
# Let's test with a tag that matches multiple in one category.
|
||||
# "bass" in Sound design matches tp1; only 1 result. Use limit=1 to confirm.
|
||||
resp = await client.get(f"{TOPICS_URL}/sound-design/bass?limit=1")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert len(data["items"]) <= 1
|
||||
|
||||
|
||||
# ── Creator Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue