# S07: Search — Per-Section Embeddings + Deep Linking

**Goal:** Search returns section-level results for v2 technique pages, with deep links that scroll to the target section.
**Demo:** After this: Search 'LFO grain position' → section-level result → click → navigates to page#section and scrolls.

## Tasks
- [x] **T01: Added per-section embedding to stage 6 for v2 technique pages with section-level Qdrant upsert, search enrichment, and schema fields** — Add per-section embedding to stage 6 for v2 technique pages. Extend QdrantManager with upsert_technique_sections() and delete_sections_by_page_id(). Extend SearchService._enrich_qdrant_results() with technique_section handling. Add section_anchor and section_heading to SearchResultItem schema. Write unit tests.

## Steps

1. **Add `section_anchor` and `section_heading` to SearchResultItem** in `backend/schemas.py`. Both optional strings defaulting to empty.

2. **Add `_slugify_heading()` helper** to `backend/pipeline/stages.py` — Python equivalent of the frontend's slugify: `re.sub(r'[^a-z0-9]+', '-', heading.lower()).strip('-')`. This must produce identical output to `frontend/src/components/TableOfContents.tsx` slugify.

3. **Add `delete_sections_by_page_id()` to QdrantManager** in `backend/pipeline/qdrant_client.py`. Uses a Qdrant filter on `payload.page_id` AND `payload.type == 'technique_section'` to delete stale section points before re-upserting. Non-blocking (logs warning on failure).

4. **Add `upsert_technique_sections()` to QdrantManager** — follows the same pattern as `upsert_technique_pages()`. Deterministic UUID: `uuid5(_QDRANT_NAMESPACE, f'ts:{page_id}:{section_slug}')`. Payload includes: type='technique_section', page_id, creator_id, creator_name, title (page title), slug (page slug), section_heading, section_anchor, topic_category, topic_tags, summary (section content truncated to 200 chars).

5. **Extend stage 6 to embed v2 sections**. After the existing technique page embedding block, add a new block that iterates pages where `body_sections_format == 'v2'` and `body_sections` is a list. For each section: build embed text as `'{creator_name} {page_title} — {section_heading}: {section_content} {subsection_contents}'`, build metadata dict with section_anchor from `_slugify_heading(section.heading)`. Before upserting, call `delete_sections_by_page_id()` for each page to remove stale points. Then batch embed and upsert. Log count of section points.

6. **Add `technique_section` branch to `_enrich_qdrant_results()`** in `backend/search_service.py`. Map payload fields: type='technique_section', title=section_heading, slug=page_slug, technique_page_slug=page_slug, section_anchor from payload, section_heading from payload. Creator resolution same as other types.

7. **Write unit tests** in `backend/pipeline/test_section_embedding.py`. Test: (a) _slugify_heading produces correct slugs for various headings, (b) section embed text construction includes creator/page/section context, (c) delete_sections_by_page_id is called before upsert, (d) v1 pages are skipped, (e) upsert_technique_sections builds correct payloads with deterministic UUIDs.

## Must-Haves

- [ ] _slugify_heading output matches frontend slugify for: 'Grain Position Control' → 'grain-position-control', 'LFO Routing & Modulation' → 'lfo-routing---modulation' (consecutive special chars become single hyphen — verify actual frontend behavior)
- [ ] Deterministic UUIDs: same page+section always overwrites the same Qdrant point
- [ ] delete_sections_by_page_id called before upsert to prevent orphan points from heading renames
- [ ] v1 pages (body_sections_format != 'v2') produce zero section points
- [ ] SearchResultItem.section_anchor populated for technique_section results
- [ ] Existing technique_page and key_moment enrichment unchanged

## Failure Modes

| Dependency | On error | On timeout | On malformed response |
|------------|----------|-----------|----------------------|
| Qdrant | Log WARNING, skip section upsert (non-blocking) | Same — stage 6 is best-effort | Skip point, continue |
| Embedding API | Log WARNING, skip section embedding | Same | Skip batch, continue |
| body_sections JSONB | Skip page if not a list or missing heading field | N/A | Log WARNING, skip malformed section |

## Negative Tests

- Page with body_sections_format='v1' → zero section points
- Page with body_sections=None → zero section points
- Section with empty heading → skipped
- Section with subsections but no direct content → subsection content still embedded
  - Estimate: 2h
  - Files: backend/schemas.py, backend/pipeline/stages.py, backend/pipeline/qdrant_client.py, backend/search_service.py, backend/pipeline/test_section_embedding.py
  - Verify: PYTHONPATH=backend python -m pytest backend/pipeline/test_section_embedding.py -v && PYTHONPATH=backend python -c "from pipeline.stages import _slugify_heading; assert _slugify_heading('Grain Position Control') == 'grain-position-control'; print('slugify OK')" && grep -q 'section_anchor' backend/schemas.py && grep -q 'technique_section' backend/search_service.py
- [x] **T02: Added technique_section result rendering with Section badge, deep links to page#anchor, and generalized hash scrolling for all anchor types** — Extend TechniquePage hash scrolling to handle section anchors (not just #km-). Update search result components to display and link technique_section results. Add section_anchor and section_heading to the TypeScript SearchResultItem type.

## Steps

1. **Add `section_anchor` and `section_heading` to SearchResultItem** in `frontend/src/api/public-client.ts`. Both optional strings.

2. **Generalize hash scroll in TechniquePage.tsx**. The existing useEffect (line ~175) only scrolls for `#km-` hashes. Change it to scroll to ANY hash fragment after technique data loads: `const hash = window.location.hash.slice(1); if (hash) { const el = document.getElementById(hash); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); }`. This handles both key moment hashes and section anchors.

3. **Add `technique_section` branch to `getSearchResultLink()`** in `frontend/src/pages/SearchResults.tsx`. If `item.type === 'technique_section'`, return `/techniques/${item.technique_page_slug}#${item.section_anchor}`. Place this before the existing `key_moment` branch.

4. **Update `SearchResultCard` badge display** — add a 'Section' label for technique_section type. Show section_heading as subtitle context if present.

5. **Update `SearchAutocomplete` autocomplete results** in `frontend/src/components/SearchAutocomplete.tsx`. The type label map (line ~165) needs `technique_section: 'Section'`. The link at line ~221 (`/techniques/${item.slug}`) needs the same section-aware logic: if type is technique_section, link to `/techniques/${item.technique_page_slug}#${item.section_anchor}`.

6. **Update result filtering** in SearchResults.tsx PartialMatchResults — add technique_section to the filter groups (currently only filters technique_page and key_moment).

7. **Build and verify** — `cd frontend && npm run build` must pass with zero errors.

## Must-Haves

- [ ] Hash scroll works for section anchors (e.g., #grain-position-control)
- [ ] Hash scroll still works for key moment anchors (e.g., #km-some-moment)
- [ ] technique_section results link to /techniques/{slug}#{anchor}
- [ ] Badge shows 'Section' for technique_section results
- [ ] Autocomplete links section results correctly
- [ ] Frontend builds with zero TypeScript errors
  - Estimate: 1h
  - Files: frontend/src/api/public-client.ts, frontend/src/pages/TechniquePage.tsx, frontend/src/pages/SearchResults.tsx, frontend/src/components/SearchAutocomplete.tsx
  - Verify: cd frontend && npm run build 2>&1 | tail -5 && echo 'Build OK'
