---
id: S01
parent: M008
milestone: M008
provides:
  - technique_page_slug field on all SearchResultItem responses
  - Hash-scroll anchors (km-{id}) on key moment list items in TechniquePage
requires:
  []
affects:
  []
key_files:
  - backend/schemas.py
  - backend/search_service.py
  - backend/pipeline/stages.py
  - backend/pipeline/qdrant_client.py
  - backend/tests/test_search.py
  - frontend/src/api/public-client.ts
  - frontend/src/pages/SearchResults.tsx
  - frontend/src/pages/TechniquePage.tsx
key_decisions:
  - technique_page_slug read from Qdrant payload for key moments, from slug for technique pages, with title-based fallback for old data
  - outerjoin to TechniquePage in keyword search handles NULL technique_page_id gracefully
  - Key moment hash anchor uses km.id (UUID) for uniqueness
  - Fallback for missing technique_page_slug re-searches by title instead of 404ing
patterns_established:
  - Cross-entity link resolution: when search results reference a parent entity, resolve the parent slug at query time (DB join for keyword, payload enrichment for semantic) rather than expecting the frontend to make a second API call
  - Hash-scroll pattern: anchor IDs on target elements + useEffect that fires after data load to scrollIntoView
observability_surfaces:
  - none
drill_down_paths:
  - .gsd/milestones/M008/slices/S01/tasks/T01-SUMMARY.md
  - .gsd/milestones/M008/slices/S01/tasks/T02-SUMMARY.md
duration: ""
verification_result: passed
completed_at: 2026-03-31T05:05:15.454Z
blocker_discovered: false
---

# S01: Fix Key Moment Search Links

**Key moment search results now link to their parent technique page and scroll to the specific moment, instead of 404ing.**

## What Happened

Key moment search results were broken — clicking one produced a 404 because the frontend tried to navigate to `/techniques/{moment_slug}` but moments don't have their own pages. The fix required coordinated backend and frontend changes across two tasks.

**T01 (Backend):** Added `technique_page_slug` to the search result data flow. Four areas changed: (1) `SearchResultItem` schema gained a `technique_page_slug` field. (2) Qdrant payload enrichment in stage 6 now includes `slug` on technique page dicts and `technique_page_slug`/`technique_page_id` on key moment dicts via a `page_id_to_slug` mapping. (3) The search service's `_enrich_results()` reads `technique_page_slug` from Qdrant payloads; `keyword_search()` uses an outerjoin to TechniquePage to resolve the parent slug for key moments. (4) Three new integration tests verify correct slug population for technique pages, key moments with parents, and orphan moments.

**T02 (Frontend):** Three changes: (1) `SearchResultCard` routing now sends key moment clicks to `/techniques/{parent_slug}#km-{id}` (with a re-search fallback if no parent slug exists). (2) Key moment `<li>` elements in `TechniquePage` gained `id={`km-${km.id}`}` anchor attributes. (3) A `useEffect` scrolls to the hash target after technique data loads, using `scrollIntoView({ behavior: 'smooth', block: 'start' })`.

## Verification

Backend: All 8 tests pass (5 existing + 3 new) via `python -m pytest tests/test_search.py -v` inside chrysopedia-api container on ub01 (2.88s). Frontend: `npm run build` passes with zero TypeScript errors, producing a clean production bundle (735ms).

## Requirements Advanced

- R005 — Key moment search results no longer 404 — they link to the parent technique page with hash-scroll to the specific moment
- R015 — Eliminating the 404 dead-end on key moment clicks removes a significant time-waster from the search-to-read flow

## Requirements Validated

None.

## New Requirements Surfaced

None.

## Requirements Invalidated or Re-scoped

None.

## Deviations

Fixed pre-existing bug in test seed data (ProcessingStatus.extracted → ProcessingStatus.complete). Backend tests run via SSH into container on ub01 since DB port is localhost-only.

## Known Limitations

Existing Qdrant data won't have slug fields until re-indexed. The title-based slug fallback in _enrich_results covers old data for semantic search, but keyword search always has the correct slug from the DB join. QdrantManager still uses random UUIDs for point IDs (pre-existing issue — re-indexing creates duplicates).

## Follow-ups

Re-index Qdrant after deployment so all points carry slug fields (eliminates fallback path). Address QdrantManager random UUID issue before next bulk re-index to avoid point duplication.

## Files Created/Modified

- `backend/schemas.py` — Added technique_page_slug: str = '' to SearchResultItem
- `backend/search_service.py` — Added technique_page_slug population in _enrich_results (semantic) and keyword_search (DB join)
- `backend/pipeline/stages.py` — Stage 6 now includes slug in technique page Qdrant dicts, technique_page_slug/technique_page_id in key moment dicts
- `backend/pipeline/qdrant_client.py` — Updated payload structure documentation (no functional change — stages.py builds the dicts)
- `backend/tests/test_search.py` — 3 new keyword search tests for technique_page_slug; fixed ProcessingStatus seed data bug
- `frontend/src/api/public-client.ts` — Added technique_page_slug to SearchResultItem interface
- `frontend/src/pages/SearchResults.tsx` — Key moment links now route to /techniques/{parent_slug}#km-{id} with re-search fallback
- `frontend/src/pages/TechniquePage.tsx` — Added km-{id} anchor IDs to key moment list items; added useEffect for hash-scroll on load
