chore: remove .gsd/ from tracking, add to .gitignore

This commit is contained in:
jlightner 2026-04-13 23:50:05 -05:00
parent 26a5d0e0ef
commit 1013a333b1
2101 changed files with 1 additions and 254934 deletions

6
.gitignore vendored
View file

@ -1,9 +1,5 @@
.bg-shell/
.gsd/gsd.db
.gsd/gsd.db-shm
.gsd/gsd.db-wal
.gsd/event-log.jsonl
.gsd/state-manifest.json
.gsd/
# ── GSD baseline (auto-generated) ──
.DS_Store

View file

@ -1,54 +0,0 @@
# Decisions Register
<!-- Append-only. Never edit or remove existing rows.
To reverse a decision, add a new row that supersedes it.
Read this file at the start of any planning or research phase. -->
| # | When | Scope | Decision | Choice | Rationale | Revisable? | Made By |
|---|------|-------|----------|--------|-----------|------------|---------|
| D001 | | architecture | Storage layer selection | PostgreSQL for structured data, Qdrant (existing instance at 10.0.0.10) for vector embeddings, local filesystem for transcript JSON files | Spec specifies PostgreSQL for JSONB support and Qdrant already running on hypervisor. File store on local filesystem organized by creator slug. Qdrant gets a dedicated collection, not a new instance. | Yes | collaborative |
| D002 | | architecture | Timestamp handling for asyncpg with TIMESTAMP WITHOUT TIME ZONE columns | Use datetime.now(timezone.utc).replace(tzinfo=None) in _now() helper | asyncpg rejects timezone-aware datetimes for TIMESTAMP WITHOUT TIME ZONE columns. Stripping tzinfo at the application layer preserves UTC semantics while staying compatible with the existing schema. | Yes | agent |
| D003 | | requirement | R002 Transcript Ingestion API status | validated | 6 passing integration tests prove the full POST /api/v1/ingest flow: creator auto-detection, SourceVideo upsert, TranscriptSegment bulk insert, raw JSON persistence, idempotent re-upload, and invalid input rejection. | Yes | agent |
| D004 | | architecture | Sync vs async approach for Celery worker pipeline tasks | Use sync openai.OpenAI, sync QdrantClient, and sync SQLAlchemy (create_engine with psycopg2) inside Celery tasks. Convert DATABASE_URL from postgresql+asyncpg:// to postgresql:// for the sync engine. | Celery workers run in a synchronous context. Using asyncio.run() inside tasks risks nested event loop errors with gevent/eventlet workers. Using sync clients throughout eliminates this class of bug entirely. The async engine/session from database.py is only used by FastAPI (ASGI); the worker gets its own sync engine. | Yes | agent |
| D005 | | architecture | Embedding/Qdrant failure handling strategy in pipeline | Embedding/Qdrant failures (stage 6) log errors but do not fail the pipeline. Processing_status is set by stages 2-5 only. Embeddings can be regenerated by manual re-trigger. | Qdrant is at 10.0.0.10 on the hypervisor network and may not be reachable during all pipeline runs. Making embedding a non-blocking side-effect ensures core pipeline output (KeyMoments, TechniquePages in PostgreSQL) is never lost due to vector store issues. The manual re-trigger endpoint allows regenerating embeddings at any time. | Yes | agent |
| D006 | | requirement | R013 Prompt Template System status | validated | 4 prompt template files in prompts/ directory loaded from configurable settings.prompts_path. Templates use XML-style content fencing. Pipeline stages read templates from disk at runtime, enabling edits without code changes. Manual re-trigger endpoint (POST /api/v1/pipeline/trigger/{video_id}) allows re-processing after prompt edits. | Yes | agent |
| D007 | M001/S04 | architecture | Runtime review mode toggle persistence mechanism | Store review mode toggle in Redis key `chrysopedia:review_mode` with async redis client. Fall back to `settings.review_mode` config default when key is absent. | The config.py `review_mode` setting is loaded via lru_cache from environment variables and cannot be mutated at runtime. Redis is already used by the project (Celery broker, stage 4 classification data) so it adds no new infrastructure. A system_settings DB table would work but Redis is simpler for a single boolean toggle on a single-admin tool. The pipeline's stages.py reads settings.review_mode from config — the admin toggle only affects new pipeline runs if stages.py is updated to check Redis too, but that's deferred since the toggle is primarily a UI-level concept for the review queue. | Yes | agent |
| D008 | M001/S04 | requirement | R004 Review Queue UI status | validated | All R004 capabilities delivered and verified: 9 API endpoints (approve, reject, edit, split, merge, queue list, stats, mode get/set) with 24 passing integration tests covering happy paths and error boundaries. React+TypeScript frontend with queue page (filter tabs, stats, pagination), moment detail page (all review actions with modals), and review-vs-auto mode toggle. Frontend builds with zero TypeScript errors. | Yes | agent |
| D009 | M001/S05 | architecture | Async search service pattern for FastAPI request path | Create a separate `SearchService` class using `openai.AsyncOpenAI` and `qdrant_client.AsyncQdrantClient` for the search endpoint. Keep existing sync `EmbeddingClient` and `QdrantManager` for Celery pipeline. Search endpoint has 300ms timeout on embedding API and falls back to SQL ILIKE keyword search on Qdrant/embedding failure. | The existing EmbeddingClient and QdrantManager are sync (using `openai.OpenAI` and `QdrantClient`) because Celery tasks run synchronously. FastAPI request handlers are async — reusing sync clients would block the event loop. Creating a thin async wrapper avoids modifying the battle-tested pipeline code while providing non-blocking search. The 300ms timeout and keyword fallback ensure the search endpoint always returns results, even when Qdrant or the embedding service is degraded. | Yes | agent |
| D010 | M001/S05 | requirement | R005 Search-First Web UI status | validated | Search endpoint (GET /api/v1/search) with async embedding + Qdrant + keyword fallback implemented and tested. Frontend Home.tsx has prominent search bar with 300ms debounced typeahead, scope toggle via URL params, nav cards for Topics/Creators, Recently Added section. SearchResults.tsx displays grouped results. 5 integration tests verify search happy path, empty query, keyword fallback, scope filter, and no-results. Frontend production build succeeds with zero TypeScript errors. | Yes | agent |
| D011 | M001/S05 | requirement | R006 Technique Page Display status | validated | TechniquePage.tsx renders all specified sections: header with topic_category badge and topic_tags pills, creator name linked to creator detail, source_quality indicator, amber banner for unstructured content, body_sections JSONB prose (handles both string and object values), key moments index ordered by start_time, signal chains, plugins pill list, and related techniques links. Backend GET /api/v1/techniques/{slug} returns full detail with eager-loaded key_moments and related links. 404 for unknown slug tested. | Yes | agent |
| D012 | M001/S05 | requirement | R007 Creators Browse Page status | validated | CreatorsBrowse.tsx implements genre filter pills, type-to-narrow name filter, sort toggle (Random default/A-Z/Views). Each creator row shows name, genre tags, technique_count, video_count. Links to CreatorDetail page. Backend GET /api/v1/creators supports sort=random\|alpha\|views and genre filter. Integration tests verify random sort, alpha sort, genre filter, detail endpoint, 404, and counts. | Yes | agent |
| D013 | M001/S05 | requirement | R008 Topics Browse Page status | validated | TopicsBrowse.tsx renders two-level topic hierarchy (6 categories from canonical_tags.yaml with expandable sub-topics showing technique_count and creator_count). Filter input narrows categories/sub-topics. Clicking sub-topic navigates to search with scope=topics. Backend GET /api/v1/topics aggregates counts from DB per sub-topic. Integration test verifies topic hierarchy response shape. | Yes | agent |
| D014 | M001/S05 | requirement | R014 Creator Equity status | validated | CreatorsBrowse.tsx defaults to sort=random. Backend uses func.random() ORDER BY for randomized sort. Integration test verifies random sort returns all creators (order may vary). All creators get equal visual weight in the UI — no featured/highlighted treatment. Equal-weight row layout confirmed in CSS. | Yes | agent |
| D015 | M002/S01 | architecture | Docker network subnet for Chrysopedia on ub01 | 172.32.0.0/24 — free on ub01, replaces 172.24.0.0/24 which is used by xpltd_docs_default | 172.24.0.0/24 was already allocated to xpltd_docs_default network on ub01. Scanned all existing subnets and 172.32.0.0/24 was the first unoccupied /24 in the 172.x range. | Yes | agent |
| D016 | M002/S01 | architecture | Embedding service for the pipeline and search — OpenWebUI doesn't serve /v1/embeddings | Add Ollama container (ollama/ollama:latest) to the compose stack running nomic-embed-text on CPU | The FYN OpenWebUI instance at chat.forgetyour.name returns 404/500 on /v1/embeddings. No Ollama instance exists on the network. Ollama provides the standard OpenAI-compatible /v1/embeddings endpoint that both EmbeddingClient and SearchService expect. | Yes | agent |
| D017 | | frontend | CSS theming approach for dark mode | 77 semantic CSS custom properties in :root block, all hardcoded colors replaced with var(--*) references | A complete variable-based palette enables theme switching, ensures consistency, and makes future color changes single-point edits. 77 tokens (vs ~30 initially planned) were needed to cover all semantic contexts — badges, buttons, surfaces, text, accents, shadows, and status states. Cyan (#22d3ee) replaces indigo as the accent color throughout. | Yes | agent |
| D018 | M004/S04 | architecture | Version snapshot failure handling strategy in stage 5 | Best-effort versioning — snapshot INSERT failure logs ERROR but does not block page update (existing content overwrites proceed regardless) | Versioning is a diagnostic/benchmarking feature. Losing a version snapshot is far less damaging than failing the entire page synthesis. Follows the same non-blocking side-effect pattern established in D005 for embedding/Qdrant failures. | Yes | agent |
| D019 | M005/S02 | frontend-layout | Technique page layout structure | CSS grid 2-column layout: 1fr main + 22rem sticky sidebar, collapsing to single column at 768px. Page max-width widened from 48rem to 64rem. | 22rem sidebar provides enough room for moment cards and plugin lists without cramming. 64rem total width accommodates both columns comfortably on standard desktop displays. Sticky sidebar keeps navigation aids visible while scrolling prose. 768px breakpoint aligns with existing mobile styles in the codebase. | Yes | agent |
| D020 | M006/S05 | frontend | Topics page card layout visual differentiation approach | 3px colored left border + small colored dot next to category name, using existing badge CSS custom properties per category | Subtler than a full colored header — maintains dark theme cohesion while providing clear visual differentiation between 7 category cards. Reuses existing --color-badge-cat-*-bg/text custom properties, avoiding new color definitions. | Yes | agent |
| D021 | M011 | scope | Which UI/UX assessment findings to implement in M011 | 12 of 16 findings approved; F01 (beginner paths), F02 (YouTube links), F03 (hide admin), F15 (CTA label) denied | User triaged each finding individually. Denied F01 because audience knows what they want. Denied F02 because no video URLs / don't want to link out. Denied F03 because admin dropdown is fine as-is. Denied F15 as low-value. | Yes | human |
| D022 | | requirement | R025 | validated | useDocumentTitle hook called in all 10 pages. Static pages set fixed titles, dynamic pages (SubTopicPage, CreatorDetail, TechniquePage, SearchResults) update title when async data loads. | Yes | agent |
| D023 | M012/S01 | architecture | Qdrant embedding text enrichment strategy | Prepend creator_name and join topic_tags into embedding text for technique pages and key moments. Batch-resolve creator names at stage 6 start. | Semantic search now surfaces results for creator-name queries and tag-specific queries. Batch resolution avoids N+1 lookups during embedding. Reindex-all endpoint enables one-shot re-embedding after text composition changes. | Yes | agent |
| D024 | M014/S01 | architecture | Content model for sections with subsections | Sections with subsections use empty-string content field; substance lives in subsections | Avoids duplication between section-level content and subsection content. The section heading serves as H2 container; all prose lives in subsection content fields. Sections without subsections use the section content field directly. | Yes | agent |
| D025 | M015 | architecture | Search query storage and popular searches architecture | PostgreSQL search_log table + Redis read-through cache with 5-min TTL | PostgreSQL gives full historical data for future analytics (zero-result queries, time-of-day patterns). Redis cache prevents DB query on every homepage load. 5-min TTL balances freshness with load. Volume is tiny at current scale. | Yes | collaborative |
| D026 | | requirement | R033 | validated | Creators browse page shows "Last updated: Apr 2/3" per creator with techniques, omits for 0-technique creators. Homepage recently-added cards show subtle date stamps. Both verified live on ub01:8096. | Yes | agent |
| D027 | | requirement | R034 | validated | Homepage renders stats block with real counts from the API: GET /api/v1/stats returns {"technique_count":21,"creator_count":7}, and the frontend scorecard displays "21 ARTICLES" and "7 CREATORS" in cyan-on-dark design. Visual and API verification both pass. | Yes | agent |
| D028 | | requirement | R036 | validated | AdminDropdown.tsx now opens on hover at desktop widths (≥769px) via matchMedia guard with 150ms leave delay, while mobile retains tap-to-toggle. Build passes. Satisfies R036 criteria. | Yes | agent |
| D029 | | requirement | R039 | validated | Build output confirms favicon.svg, favicon-32.png, apple-touch-icon.png, og-image.png in dist/. index.html contains all required OG, Twitter card, and favicon meta tags. Inline SVG logo mark present in header. All slice plan verification checks pass. | Yes | agent |
| D030 | | ui | IntersectionObserver rootMargin for ToC scroll-spy | rootMargin '0px 0px -70% 0px' — section is active when it enters top 30% of viewport | Triggers early enough that the active indicator updates before the user reaches the section content, providing a predictive reading position indicator. The 30% threshold balances between premature switching and late switching. | Yes | agent |
| D031 | | architecture | Phase 2 milestone structure | 8 milestones (M018-M025): Research → Foundations → Core Experiences → Intelligence Online → Creator Tools → MVP Integration → Polish → Hardening. Pipeline A (frontend) and Pipeline B (backend) slices run in parallel within each milestone. | Maps directly to the Sprint 0-8 plan. Each milestone has a deploy gate. Parallel slices maximize throughput. Integration points (INT-1 through INT-4) converge at defined milestones. | Yes | collaborative |
| D032 | | architecture | RAG framework for Phase 2 chat and knowledge graph | LightRAG with Qdrant vector backend and NetworkX graph (MVP), Neo4j migration path at >100K entities | Graph-enhanced retrieval fits music production's relational knowledge. LightRAG supports Qdrant natively (no migration), OpenAI-compatible APIs (matches DGX Sparks), incremental updates (critical for continuous upload). Cheaper than GraphRAG. NetworkX is zero-ops for MVP scale. | Yes | collaborative |
| D033 | | architecture | Monetization approach for Phase 2 | Demo build with functional UI and "Coming Soon" payment placeholders. Stripe Connect deferred to Phase 3. | Phase 2 is a creator recruitment demo. Show working product, not a pitch deck. Tier UI exists but payment buttons show styled "Coming Soon" modals. Recruit creators first, build commerce layer after buy-in. | Yes | collaborative |
| D034 | | architecture | Documentation strategy for Phase 2 | Forgejo wiki at forgejo.xpltd.co populated incrementally — KB slice at end of every milestone | KB stays current by documenting what just shipped at each milestone boundary. Final comprehensive pass in M025. Newcomers can onboard at any point during Phase 2 development. | Yes | collaborative |
| D035 | | architecture | File/object storage for creator posts, shorts, and file distribution | MinIO (S3-compatible) self-hosted on ub01 home server stack | Docker-native, S3-compatible API for signed URLs with expiration. Already fits the self-hosted infrastructure model. Handles presets, sample packs, shorts output, and gated downloads. | Yes | collaborative |
| D036 | M019/S02 | architecture | JWT auth configuration for creator authentication | HS256 with existing app_secret_key, 24-hour expiry, OAuth2PasswordBearer at /api/v1/auth/login | Reuses existing secret from config.py settings. 24-hour expiry balances convenience with security for a single-admin/invite-only tool. OAuth2PasswordBearer integrates with FastAPI's dependency injection and auto-generates OpenAPI security schemes. | Yes | agent |
| D037 | | architecture | Search impressions query strategy for creator dashboard | Exact case-insensitive title match via EXISTS subquery against SearchLog | MVP approach — counts SearchLog rows where query exactly matches (case-insensitive) any of the creator's technique page titles. Sufficient for initial dashboard. Can be expanded to ILIKE partial matching or full-text search later when more search data accumulates. | Yes | agent |
| D038 | | infrastructure | Primary git remote for chrysopedia | git.xpltd.co (Forgejo) instead of github.com | Consolidating on self-hosted Forgejo instance at git.xpltd.co. Wiki is already there. Single source of truth. | Yes | human |
| D039 | | architecture | LightRAG vs Qdrant search execution strategy | Sequential with fallback — LightRAG first, Qdrant only on LightRAG failure/empty, not parallel | Running both in parallel would double latency overhead. LightRAG is the primary engine; Qdrant is a safety net. Sequential approach reduces load and simplifies result merging. | Yes | agent |
| D040 | M021/S02 | architecture | Creator-scoped retrieval cascade strategy | Sequential 4-tier cascade (creator → domain → global → none) with ll_keywords scoping and post-filtering | Sequential cascade is simpler than parallel-with-priority and avoids wasted LightRAG calls when early tiers succeed. ll_keywords hints LightRAG's retrieval without hard constraints. Post-filtering on tier 1 ensures strict creator scoping while 3x oversampling compensates for filtering losses. Domain tier uses ≥2 page threshold to avoid noise from sparse creators. | Yes | agent |
| D041 | M022/S05 | architecture | Highlight scorer weight distribution for 10-dimension model | Original 7 dimensions reduced proportionally, new 3 audio proxy dimensions (speech_rate_variance, pause_density, speaking_pace) allocated 0.22 total weight. Audio dims default to 0.5 (neutral) when word_timings unavailable for backward compatibility. | Audio proxy signals derived from word-level timing data provide meaningful highlight quality indicators without requiring raw audio analysis (librosa). Neutral fallback ensures existing scoring paths are unaffected. | Yes | agent |
| D042 | M023/S01 | architecture | Rich text editor for creator posts | Tiptap (headless, React) with StarterKit + Link + Placeholder extensions. Store Tiptap JSON as canonical format in JSONB column, render client-side via @tiptap/html. | Headless architecture fits dark theme customization. Large ecosystem, well-maintained. JSON storage is lossless and enables future server-side rendering. No HTML sanitization needed since canonical format is structured JSON. | Yes | agent |
| D043 | M023/S02 | architecture | Personality weight → system prompt modulation strategy | 3-tier intensity (<0.4 subtle reference, 0.4-0.8 adopt voice, 0.8 fully embody) with temperature scaling 0.30.5 linear on weight | Stepped intensity prevents jarring persona at low weights while allowing full creator voice at high values. Temperature stays in 0.3-0.5 range to keep responses factually grounded even at maximum personality wider ranges risk hallucination in a knowledge-base context. | Yes | agent |
| D044 | M023/S04 | architecture | Personality weight → system prompt modulation strategy (revision) | 5-tier continuous interpolation replacing 3-tier step function. Progressive field inclusion: weight < 0.2 = no personality block; 0.2+ adds basic tone; 0.4+ adds descriptors/explanation approach; 0.6+ adds signature phrases (count scaled with weight); 0.8+ adds full vocabulary/style markers; 0.9+ adds summary paragraph. Temperature scaling unchanged (0.3 + weight * 0.2). | 3-tier step function had jarring transitions at 0.4 and 0.8 boundaries. Continuous interpolation with progressive field inclusion gives finer control encyclopedic responses stay clean at low weights while high weights pull in the full personality profile gradually. The 0.0-0.19 dead zone ensures purely encyclopedic mode remains truly encyclopedic with zero personality artifacts. | Yes | agent |
| D045 | M025/S01 | library | Signed unsubscribe token library for email digests | PyJWT instead of itsdangerous | PyJWT was already a dependency (used for auth tokens). Avoids adding itsdangerous as a new package. JWT exp claim provides built-in expiry handling for the 30-day token validity. | Yes | agent |
| D046 | M025/S10 | feature | Whether to accept current sticky title bar as meeting R041 or implement the specified ReadingHeader | Implement proper ReadingHeader component matching R041 spec | The current sticky title bar is always visible and shows no section tracking — it doesn't match R041's spec (thin bar, appears on scroll-past, shows current section name, slide transition). The infrastructure already exists (activeId, titleBarRef, IntersectionObserver), so the implementation is ~80 lines TSX + CSS. Since M025 is about formal validation, accepting a partial implementation undermines the validation exercise. | Yes | agent |

View file

@ -1,406 +0,0 @@
# KNOWLEDGE
## Healthcheck in slim Python Docker images: use os.kill(1,0) not pgrep
**Context:** The `python:3.x-slim` Docker image doesn't include `procps`, so `pgrep -f watcher.py` fails in healthcheck commands. This applies to any slim-based image where a healthcheck needs to verify the main process is alive.
**Fix:** Use `python -c "import os; os.kill(1, 0)"` as the healthcheck command. PID 1 is always the container's entrypoint process. `os.kill(pid, 0)` checks if the process exists without sending a signal — it raises `ProcessLookupError` if the process is dead, causing the healthcheck to fail. No external tools needed.
## SQLAlchemy column names that shadow ORM functions
**Context:** If a model has a column named `relationship` (or any other ORM function name like `query`, `metadata`), Python resolves the name to the column's `MappedColumn` descriptor within the class body. This causes `relationship(...)` calls below it to fail with "MappedColumn object is not callable".
**Fix:** Import with an alias: `from sqlalchemy.orm import relationship as sa_relationship` and use `sa_relationship(...)` throughout. Alternatively, rename the database column, but the spec defines `relationship` as the column name so we preserved it.
## Docker Compose variable interpolation and `:?` syntax
**Context:** `${VAR:?error message}` in docker-compose.yml makes `docker compose config` fail when the variable is unset, even if `env_file: required: false` is used. The `required: false` only controls whether the `.env` file must exist — it does NOT provide defaults for variables referenced with `:?`.
**Fix:** Use `${VAR:-default}` for variables that need a fallback, or ensure the variable is always set in the environment.
## Host port 8000 conflict with kerf-engine
**Context:** The kerf-engine Docker container (`kerf-engine-kerf-engine-1`) binds to `0.0.0.0:8000`. When testing the Chrysopedia API locally (outside Docker), curl requests to `localhost:8000` hit the kerf-engine container instead of the local uvicorn process, even when uvicorn binds to `127.0.0.1:8000`.
**Fix:** Use an alternate port (e.g., 8001) for local testing. In Docker Compose, chrysopedia-api maps `127.0.0.1:8000:8000` which is fine because it goes through Docker's port forwarding, but will conflict with kerf-engine if both stacks run simultaneously. Consider changing one project's external port mapping.
## asyncpg rejects timezone-aware datetimes for TIMESTAMP WITHOUT TIME ZONE columns
**Context:** SQLAlchemy models using `default=datetime.now(timezone.utc)` produce timezone-aware Python datetimes. When the PostgreSQL column type is `TIMESTAMP WITHOUT TIME ZONE` (the default), asyncpg raises `DataError: can't subtract offset-naive and offset-aware datetimes`. This only surfaces when running against a real PostgreSQL database — SQLite-based tests won't catch it.
**Fix:** Use `datetime.now(timezone.utc).replace(tzinfo=None)` in the `_now()` helper, or change the column type to `TIMESTAMP WITH TIME ZONE`. We chose the former since the existing schema uses naive timestamps.
## asyncpg NullPool required for pytest-asyncio integration tests
**Context:** When using a session-scoped SQLAlchemy async engine with asyncpg in pytest-asyncio tests, the connection pool reuses connections across fixtures and test functions. This causes `InterfaceError: cannot perform operation: another operation is in progress` because the ASGI test client's session holds a connection while cleanup/verification fixtures try to use the same pool.
**Fix:** Use `poolclass=NullPool` when creating the test engine. Each connection is created fresh and immediately closed, eliminating contention. Performance cost is negligible for test suites.
## Testing Celery tasks that use sync SQLAlchemy: patch module-level globals
**Context:** Pipeline stages in `pipeline/stages.py` create their own sync SQLAlchemy engine/session via module-level `_engine` and `_SessionLocal` globals (because Celery is sync, not async). Tests need to redirect these to the test database, but the engine is created lazily at module scope.
**Fix:** Patch the module globals directly: `unittest.mock.patch.object(stages, '_engine', test_engine)` and `unittest.mock.patch.object(stages, '_SessionLocal', test_session_factory)`. This redirects all DB access in stage functions to the test database without modifying production code.
## Lazy imports in FastAPI handlers defeat simple mock patching
**Context:** When a FastAPI handler imports a function lazily (inside the function body) like `from pipeline.stages import run_pipeline`, patching `routers.ingest.run_pipeline` has no effect because the name is re-bound on every call from the source module.
**Fix:** Patch at the source module: `unittest.mock.patch('pipeline.stages.run_pipeline')`. The lazy import will pick up the mock from the source module. This applies to any handler that uses lazy imports to avoid circular dependencies at module load time.
## Separate async/sync clients for FastAPI vs Celery
**Context:** The Chrysopedia backend has both sync Celery tasks (pipeline stages using `openai.OpenAI`, `QdrantClient`, sync SQLAlchemy) and async FastAPI handlers. Reusing sync clients in async handlers blocks the event loop; reusing async clients in Celery risks nested event loop errors.
**Fix:** Create separate client classes: `SearchService` (async, for FastAPI request path) wraps `openai.AsyncOpenAI` and `AsyncQdrantClient`. The pipeline's `EmbeddingClient` and `QdrantManager` (sync, for Celery) remain untouched. This doubles the client code surface but eliminates the async/sync mismatch class of bugs entirely.
## Mocking SearchService at the router dependency level for tests
**Context:** The search endpoint creates a `SearchService` instance internally. Testing search results with real embedding API and Qdrant is fragile (external dependencies). Mocking individual `openai.AsyncOpenAI` or `AsyncQdrantClient` is complex.
**Fix:** Mock `SearchService` at the router level by patching the service instance in the endpoint function. This gives full control over search results in tests without complex async mock setup. Used in `test_search.py` — mock returns canned `SearchResponse` dicts.
## Frontend detail page without a single-resource GET endpoint
**Context:** The review queue backend has `GET /review/queue` (list, paginated) but no `GET /review/moments/{id}` for fetching a single moment. The MomentDetail page needs to display one specific moment by ID from the URL params.
**Fix:** MomentDetail fetches the full queue with `limit=500` and filters client-side by ID. This works for a single-admin tool with small datasets but would not scale. If the moment count grows significantly, add a dedicated `GET /review/moments/{id}` endpoint to the review router.
**Update (M004/S01):** Fixed properly — added `GET /review/moments/{id}` endpoint. Always add single-resource GET endpoints alongside list endpoints.
## CSS custom property count estimation
**Context:** When planning a full color abstraction pass (replacing all hardcoded hex/rgba with CSS custom properties), initial estimates based on counting unique colors will significantly undercount the required tokens.
**Rule:** Plan for 2-3x the obvious color count. Reasons: hover/focus/active state variants, badge/status color pairs (background + text), subtle vs strong accent variants, shadow colors, overlay backgrounds, and distinct semantic contexts (surface vs input vs card). M004/S02 planned ~30, needed 77.
## Chained selectinload for cross-relation field population
**Context:** When an API response needs a field from a joined relation (e.g., `key_moment.source_video.filename`), Pydantic's `from_attributes` auto-mapping can't traverse the join. The field appears as a flat attribute on the response schema but comes from a different table.
**Fix:** Chain `.selectinload()` in the query (e.g., `.selectinload(TechniquePage.key_moments).selectinload(KeyMoment.source_video)`), then post-process in a loop to copy the joined value into the schema field. This keeps the query efficient (two SELECTs) and the response schema flat.
## Pre-overwrite snapshot pattern for article versioning
**Context:** Need to track content versions without complex CDC (change data capture) infrastructure. The content lives in a single mutable row that gets overwritten on each pipeline run.
**Pattern:** Before mutating the row, SELECT its current state, serialize relevant fields to JSONB (`content_snapshot`), attach pipeline metadata (prompt file hashes, model config) as a second JSONB column, and INSERT into a versions table. Use best-effort error handling — snapshot failure logs ERROR but doesn't block the primary write. Composite unique index on (entity_id, version_number) enforces ordering at DB level.
## Stage 4 classification data stored in Redis (not DB columns)
**Context:** The KeyMoment SQLAlchemy model doesn't have `topic_tags` or `topic_category` columns. Stage 4 classification needs somewhere to store per-moment tag assignments that stage 5 can read.
**Fix:** Store classification results in Redis under key `chrysopedia:classification:{video_id}` with a 24-hour TTL. Stage 5 reads from Redis. This avoids schema migrations during initial pipeline development. The data is ephemeral — if Redis loses it, re-running stage 4 regenerates it.
## QdrantManager uses random UUIDs for point IDs
**Context:** `QdrantManager.upsert_technique_pages()` and `upsert_key_moments()` generate `uuid4()` for each Qdrant point. Re-indexing the same content creates duplicate points rather than updating existing ones.
**Fix (deferred):** Use deterministic UUIDs based on content hash (e.g., `uuid5(NAMESPACE, f"{technique_slug}:{section}")`) so re-indexing overwrites the same points. This should be addressed before running the pipeline on production data to avoid index bloat.
## Non-blocking side-effect pattern for external service calls in pipelines
**Context:** Celery pipeline stages that call external services (embedding API, Qdrant) should not fail the entire pipeline if those services are down. Stage 6 (embed_and_index) is valuable but not critical — the pipeline's primary output (technique pages in PostgreSQL) doesn't depend on it.
**Fix:** Use `max_retries=0` and a catch-all exception handler that logs WARNING and returns without raising. The pipeline orchestrator chains stage 6 after stage 5 but a failure there doesn't prevent `processing_status` from reaching its final state. This pattern applies to any "best-effort enrichment" stage in a pipeline.
## Container healthcheck tool availability varies by base image
**Context:** Qdrant (distroless-ish), Ollama, and nginx-alpine images don't have curl or wget. Standard healthcheck patterns like `curl -f http://localhost:PORT/health` fail silently inside these containers.
**Fix by image:**
- **Ollama:** Use `ollama list` (built-in CLI)
- **Qdrant:** Use `bash -c 'echo > /dev/tcp/localhost/6333'` (bash is available)
- **nginx-alpine:** Use `curl` (available) but NOT busybox `wget` (fails on localhost)
- **Celery worker (same image as API):** Use `celery -A worker inspect ping` instead of HTTP healthcheck
**Rule:** Always `docker exec <container> <healthcheck-cmd>` to verify the command works before deploying.
## XPLTD domain setup flow
**Context:** Adding a new external domain (like chrysopedia.com) to the XPLTD infrastructure requires three steps across two machines.
**Flow:**
1. **AdGuard on ub01 (10.0.0.10):** Add DNS rewrite via API: `POST /control/rewrite/add` with `{domain, answer: "10.0.0.9"}`
2. **nginx on nuc01 (10.0.0.9):** Create server block in `/etc/nginx/sites-enabled/{domain}.conf` with `proxy_pass http://10.0.0.10:{port}`
3. **Certbot on nuc01:** `sudo certbot --nginx -d {domain}` (requires external DNS A record or CNAME pointing to public IP first)
**Note:** The AD DNS servers (10.0.0.15/.16) may already have CNAME entries for domains. Check `nslookup {domain} 10.0.0.15` before assuming setup is needed. ub01's resolv.conf uses AD DNS, not AdGuard, so `curl {domain}` from ub01 requires the AD DNS chain to resolve.
## Alembic env.py sys.path needs both local and Docker paths
**Context:** Alembic's env.py imports from `database` and `models`, which live in `backend/` locally but at `/app/` (the WORKDIR) inside Docker. The original `sys.path.insert(0, "../backend")` works locally but fails in Docker.
**Fix:** Add both paths: `sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend"))` AND `sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))`. The second resolves to `/app/` inside Docker.
## Nginx stale DNS after Docker container rebuild
**Context:** When an API container behind an nginx reverse proxy in Docker Compose is rebuilt (new container ID, new internal IP), the nginx container's DNS resolver cache still points to the old IP. Requests through nginx return 502 Bad Gateway even though the API is healthy on its new IP.
**Fix:** Restart the nginx/web container after rebuilding upstream containers: `docker compose restart chrysopedia-web-8096`. Alternatively, configure nginx with `resolver 127.0.0.11 valid=10s;` in the upstream block to use Docker's embedded DNS with a short TTL, but the restart is simpler for a single-admin deployment.
## Verify prior incomplete code compiles before building on it
**Context:** M005/S01/T01 found that the PipelineEvent model, migration, and instrumentation hooks had been written in a prior pass but contained critical syntax errors (missing triple-quote docstrings, unquoted string literals, reference to nonexistent `_get_session_factory()`). The code looked complete in the file listing but had never been successfully executed.
**Rule:** When a slice plan assumes prior work is complete ("model already exists, migration already applied"), verify it actually runs: import the module, run the migration, execute a basic query. Broken code that's already committed looks identical to working code in `git log`.
## Vite build-time constant injection requires JSON.stringify
**Context:** Vite's `define` config replaces identifiers at build time with string literals. If you write `define: { __APP_VERSION__: version }` where `version` is a JS string, the replacement is the raw value without quotes — the built code gets `const v = 0.1.0` (syntax error) instead of `const v = "0.1.0"`.
**Fix:** Always wrap with `JSON.stringify`: `define: { __APP_VERSION__: JSON.stringify(version) }`. Declare the constants in `vite-env.d.ts` as `declare const __APP_VERSION__: string` for TypeScript.
## Docker ARG→ENV ordering matters for Vite/Node builds
**Context:** In a Dockerfile that runs `npm run build` to produce a static frontend bundle, Vite reads environment variables at build time. A `ARG VITE_FOO=default` alone isn't visible to Node unless also set as `ENV VITE_FOO=$VITE_FOO`. The `ENV` line must appear BEFORE the `RUN npm run build` step.
**Fix:** Pattern is `ARG VITE_FOO=default``ENV VITE_FOO=$VITE_FOO``RUN npm run build`. In docker-compose.yml, pass build args: `build: args: VITE_FOO: ${HOST_VAR:-default}`.
## Slim Python Docker images lack procps (pgrep/ps)
**Context:** The `python:3.x-slim` base image (used by Dockerfile.api) does not include `procps`, so `pgrep`, `ps`, and `pidof` are unavailable. Healthcheck commands like `pgrep -f watcher.py || exit 1` silently fail because the binary doesn't exist.
**Fix:** Use Python for process healthchecks: `python -c "import os; os.kill(1, 0)" || exit 1`. This sends signal 0 to PID 1 (the container's main process), which succeeds if the process exists without actually sending a signal. Works for any single-process container where PID 1 is the service.
## PollingObserver over inotify for ZFS/NFS folder watchers
**Context:** Python watchdog's default `Observer` uses inotify on Linux, which doesn't reliably detect file changes on ZFS datasets or NFS mounts. The watcher silently misses file creation events.
**Fix:** Use `watchdog.observers.polling.PollingObserver` instead. It polls the filesystem at configurable intervals (default 1s). Slightly higher CPU usage but works reliably on any filesystem type.
## File stability detection for folder watchers receiving SCP/rsync writes
**Context:** When files land via SCP or rsync, the filesystem sees partial writes before the transfer completes. A watcher that processes on first `on_created` event may read an incomplete file.
**Fix:** After detecting a new file, check file size at two points separated by a delay (e.g., 2 seconds). Only process when the size is stable. This handles both SCP (which writes incrementally) and rsync (which may use a temp file then rename, but the rename triggers a new event with the final size).
## Check toggle state once at initialization, not per-operation
**Context:** When a feature toggle (e.g., debug mode) controls whether extra work happens on every operation (e.g., storing full LLM I/O on every API call), checking the toggle per-operation adds latency (Redis round-trip) on every call even when the feature is off.
**Fix:** Check the toggle once when creating the callback/handler closure, and let the closure capture the boolean. The trade-off is that toggle changes don't take effect until the next pipeline run (not mid-run), which is acceptable for diagnostic features.
## Resolve cross-entity links at query time, not in the frontend
**Context:** Key moment search results 404'd because the frontend navigated to `/techniques/{moment_slug}` — but moments don't have standalone pages. The frontend had no way to know the parent technique page's slug.
**Fix:** Resolve parent entity slugs at query time: use a DB JOIN for keyword search, and enrich the Qdrant payload with the parent slug during indexing for semantic search. The frontend receives a ready-to-use link target. This eliminates the need for a second API call and prevents 404 races entirely. Apply this pattern whenever a search result or list item needs to link to a parent/related entity.
## LLM-generated topic categories have inconsistent casing
**Context:** Stage 4 classification produces topic categories with inconsistent casing across different pipeline runs (e.g., 'Sound design' vs 'Sound Design'). When these are aggregated client-side (counts, grouping), they appear as separate entries.
**Fix (deferred):** Normalize casing in stage 4 output or in a post-processing step. A quick client-side fix is `.toLowerCase()` before grouping, but the root cause is upstream data quality. Worth addressing when revisiting pipeline prompt templates.
## CSS grid-template-rows 0fr/1fr for collapse/expand animation
**Context:** Animating variable-height content (accordion, expandable sections) traditionally requires JS height measurement or max-height hacks. CSS `grid-template-rows: 0fr``1fr` with `transition: grid-template-rows 300ms` provides smooth animation. The inner content needs `overflow: hidden; min-height: 0`.
**Fix:** Wrap the collapsible content in a grid container. Toggle between `grid-template-rows: 0fr` (collapsed) and `grid-template-rows: 1fr` (expanded). No JS measurement needed. Used in TopicsBrowse.tsx for category sections.
## Keyboard shortcut deduplication in multi-instance components
**Context:** When a component with a global keyboard shortcut (e.g., Cmd+K for search focus) is rendered in multiple places on the same page (nav + mobile panel), both instances register the shortcut, causing double-fire or unexpected focus behavior.
**Fix:** Add a `globalShortcut` boolean prop (default false). Only the primary instance sets it to true. Mobile/secondary instances render without the keyboard handler. Used in SearchAutocomplete for nav vs mobile panel instances.
## border-image strips border-radius
**Context:** CSS `border-image` property overrides `border-radius`, making rounded corners disappear. This is per CSS spec — border-image replaces the entire border rendering including corner shapes.
**Fix:** If rounded corners are required, use `box-shadow` or pseudo-elements for decorative borders instead of `border-image`. For the featured technique card, box-shadow glow provides the visual distinction even though corners are square.
## Sort strategy depends on data source homogeneity
**Context:** Search results come from two sources (Qdrant semantic + keyword SQL). Applying SQL ORDER BY is impossible when results are merged from different backends. Single-source list views (subtopic pages, creator detail) pull exclusively from PostgreSQL.
**Fix:** Use Python-level sorting (`sorted()` with key functions) for mixed-source result sets. Use SQL `ORDER BY` for single-source endpoints — it's faster and handles NULLs correctly. The sort param flows from the API query string through to whichever strategy applies. Don't try to unify into one approach.
## Multi-token AND search with partial_matches fallback
**Context:** Single-pattern ILIKE search (e.g., `%keota snare%`) only matches when the exact phrase appears in one field. Users expect "keota snare" to find content where "keota" matches the creator and "snare" matches the title/tags.
**Fix:** Tokenize by whitespace. For each token, generate OR conditions across all searchable fields (title, summary, tags, category, creator name). AND all per-token conditions. When a multi-token AND query returns zero results, fall back to partial_matches: query each token separately, score results by token coverage, return top 5 with a muted UI treatment. This gives exact matches priority while still providing useful results for imprecise queries.
## Project-root symlink + sys.path bootstrap for nested Python packages
**Context:** When a Python package lives under a subdirectory (e.g., `backend/pipeline/`), `python -m pipeline.quality` fails from the project root because `pipeline` isn't on `sys.path`. Task executors worked around this with `cd backend &&` prefix, but CI/verification gates may run from project root.
**Fix:** Create a symlink at project root (`pipeline -> backend/pipeline`) so Python finds the package. Add a `sys.path` bootstrap in the package's `__init__.py` that uses `os.path.realpath(__file__)` to resolve through the symlink and insert the real parent directory (`backend/`) onto `sys.path`. This ensures sibling imports (e.g., `from config import ...`) resolve correctly. The `realpath()` call is critical — without it, the path resolves relative to the symlink location, not the real file location.
## Offset-based citation indexing for multi-source composition
**Context:** When merging new video content into an existing technique page, both old and new key moments need citation markers ([N]) in the prose. Renumbering existing citations on every merge is error-prone and invalidates cached references.
**Fix:** Use offset-based indexing: existing moments keep [0]-[N-1], new moments get [N]-[N+M-1]. The composition prompt receives the offset explicitly. This means existing citation markers remain stable across merges — only new content gets new indices appended to the end.
## Format-discriminated rendering for evolving content schemas
**Context:** Technique pages evolved from v1 (flat dict body_sections) to v2 (list-of-objects with nesting). Migrating all existing pages at once is risky and unnecessary.
**Fix:** Add a `body_sections_format` discriminator column (default 'v1'). Frontend checks the column and selects the appropriate renderer. Both v1 and v2 renderers are independent code paths — no shared logic that could break one when editing the other. New pages get v2; existing pages stay v1 until re-processed. This pattern works for any schema evolution where old and new formats coexist.
## Compound slugs for nested anchor IDs
**Context:** When a page has both H2 sections and H3 subsections, naive slugification can produce anchor ID collisions (e.g., two different headings both slugify to "overview").
**Fix:** Use compound slugs for subsections: `sectionSlug--subSlug` (double-hyphen separator). The double-hyphen is unlikely to appear in natural headings and makes the nesting relationship visible in the URL fragment. Applied in both backend (Qdrant point IDs) and frontend (DOM element IDs).
## Lifted IntersectionObserver state for multi-consumer scroll-spy
**Context:** When multiple components need the same scroll-spy data (e.g., sidebar ToC and sticky reading header both need `activeId`), duplicating IntersectionObservers wastes resources and can cause them to disagree on which section is active.
**Fix:** Lift the IntersectionObserver + activeId state to the parent page component. Pass activeId down as a prop to all consumers. This ensures a single source of truth and avoids duplicate observers. Applied in TechniquePage.tsx for TableOfContents + ReadingHeader.
## Programmatic PNG generation with Python stdlib
**Context:** Generating simple brand assets (favicons, OG images) inside a build or CI pipeline without ImageMagick, Pillow, or other external image tools.
**Fix:** Use Python's `struct` + `zlib` to write raw PNG (header, IHDR, IDAT, IEND chunks). Works for simple geometric shapes. For images larger than ~200x200, use scanline-based generation with bounding-box culling instead of naive pixel-by-pixel — the naive approach times out on 1200x630+ images.
## LightRAG Docker image lacks curl — use Python urllib for healthcheck
**Context:** The `ghcr.io/hkuds/lightrag:latest` Docker image does not include `curl`. The standard `curl -sf http://localhost:9621/health` healthcheck silently fails, leaving the container in perpetual "starting" → "unhealthy" state.
**Fix:** Use Python's stdlib urllib: `python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9621/health')"`. Must use `127.0.0.1` not `localhost` — localhost resolution can be unreliable in minimal containers. This pattern works for any Python-based Docker image that lacks curl/wget.
## passlib is incompatible with bcrypt >= 4.1
**Context:** passlib's internal bug-detection routine sends a 73+ byte test string to bcrypt's `hashpw()`. bcrypt >= 4.1 enforces the 72-byte limit strictly and raises `ValueError: password cannot be longer than 72 bytes`. This breaks passlib's `CryptContext(schemes=["bcrypt"])` on first use.
**Fix:** Replace passlib with direct `bcrypt` usage: `bcrypt.hashpw(plain.encode("utf-8"), bcrypt.gensalt())` and `bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))`. The API is simple enough that passlib's wrapper adds no value. Pin `bcrypt>=4.0,<6.0` in requirements.txt.
## Running integration tests inside Docker containers on ub01
**Context:** The test PostgreSQL database runs inside the `chrysopedia-db` container. From the dev machine (aux), port 5433 is not reachable. From ub01's host, the DB password differs from the default "changeme" in conftest.py.
**Fix:** Run tests inside the API container: `docker exec -e TEST_DATABASE_URL='postgresql+asyncpg://chrysopedia:<actual_pw>@chrysopedia-db:5432/chrysopedia_test' chrysopedia-api python -m pytest tests/...`. Copy changed files in first with `docker cp`. The container has all Python dependencies and network access to the DB container.
## passlib is incompatible with bcrypt>=4.1
**Context:** passlib[bcrypt] has been unmaintained and fails at runtime with bcrypt 4.1+ (the version installed in current Docker images). The error manifests as attribute errors or version-check failures inside passlib's bcrypt backend.
**Fix:** Use `bcrypt` directly: `bcrypt.hashpw(password.encode(), bcrypt.gensalt())` and `bcrypt.checkpw(password.encode(), hashed)`. No wrapper needed. The bcrypt library is stable and well-maintained.
## AppShell extraction for React context consumers in the root component
**Context:** When `App.tsx` wraps everything in `<AuthProvider>` but also uses `useAuth()` itself (e.g., for auth-aware nav), the hook call fails because `useAuth()` must be inside `<AuthProvider>`. Simply moving the hook into `App` doesn't work.
**Fix:** Extract an `AppShell` component that contains the Routes and any hook-consuming UI (nav, footer). `App` renders `<AuthProvider><AppShell /></AuthProvider>`. This is the standard pattern whenever the root component needs to both provide and consume a context.
## Forgejo wiki PATCH API renames pages to unnamed.md
**Context:** The Forgejo wiki API `PATCH /api/v1/repos/{owner}/{repo}/wiki/page/{pageName}` renames the underlying git file to `unnamed.md` when updating content. Each subsequent PATCH overwrites the same `unnamed.md`, destroying the original page. This happens silently — the API returns success but the page is effectively deleted and replaced with an unnamed page.
**Fix:** Never use the Forgejo wiki PATCH API for updating existing pages. Instead, use git clone → edit files → git push. For creating new pages, `POST /api/v1/repos/{owner}/{repo}/wiki/new` works correctly. For bulk updates, always use the git approach. If the PATCH API corrupts pages, recover by resetting to a pre-corruption commit and force-pushing.
## Mocking sequential httpx calls for multi-tier cascade tests
**Context:** When testing a cascade (e.g., creator → domain → global), each tier makes a separate httpx POST to the same LightRAG endpoint. Using `side_effect` with a counter-based function lets you return different mock responses for each sequential call.
**Pattern:** Create a side_effect function that tracks `call_count` and returns different mock responses based on which call number it is (1st = creator tier response, 2nd = domain tier response, 3rd = global tier response). This avoids complex URL-based routing since all tiers hit the same endpoint.
**Where:** `backend/tests/test_search.py` — cascade tests from M021/S02
## LightRAG ll_keywords for scoped retrieval
**Context:** LightRAG's `/query/data` endpoint accepts `ll_keywords` (list of strings) that bias retrieval toward matching content without hard filtering. For creator-scoped search, pass the creator's name as a keyword; for domain-scoped, pass the topic category. Combine with post-filtering for strict creator scoping (request 3x results, filter locally by creator_id).
**Where:** `backend/search_service.py``_creator_scoped_search()`, `_domain_scoped_search()`
## Named unique constraints for Celery upsert targeting
**Context:** When a Celery task needs idempotent writes (re-running on same input updates rather than duplicates), use a named unique constraint on the natural key column and target it with `INSERT ... ON CONFLICT ON CONSTRAINT <name> DO UPDATE`. The named constraint approach is more explicit than targeting the column directly and works reliably with SQLAlchemy's `insert().on_conflict_do_update(constraint=...)`.
**Where:** `backend/pipeline/stages.py``stage_highlight_detection`, constraint `uq_highlight_candidate_moment` on `key_moment_id`
## Pure-function scoring + Celery task separation
**Context:** Keep scoring logic as a pure function (no DB, no side effects) in a separate module from the Celery task that calls it. This enables unit testing with 28 tests running in 0.03s (no DB fixtures needed). The Celery task handles DB reads, calls the pure function, and writes results. Use lazy imports inside the Celery task function body to avoid circular imports at module load time.
**Where:** `backend/pipeline/highlight_scorer.py` (pure), `backend/pipeline/stages.py` (Celery wiring)
## SSE streaming protocol for chat
**Context:** The chat engine uses a 4-event SSE protocol: `sources` (citation metadata array sent first), `token` (streamed completion chunks), `done` (cascade_tier metadata), `error` (on LLM failure mid-stream). Frontend uses `fetch()` + `ReadableStream` — not EventSource — because EventSource doesn't support POST requests. Each event is `data: JSON\n\n` formatted. This ordering lets the client render source links immediately while tokens stream in.
**Where:** `backend/routers/chat.py` (SSE emitter), `frontend/src/api/chat.ts` (SSE client)
## Standalone ASGI test clients for route-level tests
**Context:** When a route depends on services that require a live database, create a standalone ASGI test client that mocks the DB session at the dependency level rather than using the shared conftest.py client. This avoids PostgreSQL dependency for tests that only need to verify request/response shape and SSE event ordering. The pattern: create a fresh FastAPI app in the test, override the DB dependency, mount the router, and use httpx.AsyncClient with ASGITransport.
**Where:** `backend/tests/test_chat.py` — chat_client fixture
## SourceVideo.transcript_path stores absolute paths
**Context:** The `transcript_path` column on `SourceVideo` stores the full absolute path (e.g., `/data/transcripts/Creator/filename.mp4.json`), not a relative path from the mount point. Code that loads transcript files should use the path directly, not join it with a base directory prefix.
**Fix:** Use `source_video.transcript_path` directly as the file path. Do not `os.path.join("/data/transcripts", transcript_path)` — that produces a double prefix.
## highlight_candidates constraint name mismatch
**Context:** The code in `stages.py` referenced `uq_highlight_candidate_moment` for the ON CONFLICT constraint, but the actual PostgreSQL constraint is named `highlight_candidates_key_moment_id_key`. This was created by Alembic's auto-naming convention rather than an explicit `UniqueConstraint(name=...)` in the model.
**Fix:** Use the actual constraint name `highlight_candidates_key_moment_id_key`. When writing ON CONFLICT upserts, always verify the actual constraint name in the database with `inspect(engine).get_unique_constraints(table_name)` rather than guessing from the model definition.
## Parallel slice execution creates cosmetic integration gaps
**Context:** M022/S01 built the HighlightQueue UI rendering 7 score dimensions. S05 (later in the milestone) expanded the scorer to 10 dimensions. The 3 new dimensions are stored in the DB but the frontend doesn't render them yet.
**Rule:** When slices that produce data (S05 scorer) and slices that consume it (S01 UI) run in the same milestone, the consumer may be built against the pre-expansion schema. Accept this as a cosmetic gap unless it breaks functionality. Track as a follow-up for the next milestone.
## conversation_id threading pattern for SSE-based chat
**Context:** Multi-turn chat requires a stable conversation identifier flowing through: POST body → backend service → Redis key → SSE done event → frontend state. The frontend generates the ID on first message (`crypto.randomUUID()`), then reads it back from the done event for subsequent messages.
**Pattern:** conversation_id is optional on the API (auto-generated if missing for backward compat). Redis key: `chrysopedia:chat:{conversation_id}` with TTL refresh on each interaction. This lets stateless API servers serve any conversation — Redis is the single source of truth for history.
## 3-tier transcript sampling for personality extraction
**Context:** Extracting a creator personality profile from transcripts. Small creators have <5 videos, large creators have 20+. Sampling strategy must adapt to corpus size while ensuring topic diversity.
**Pattern:** 3 tiers: small (≤5 videos: use all), medium (6-15: sample ~8), large (>15: sample ~10). For medium/large, use Redis classification data to group transcripts by topic and sample proportionally, ensuring the profile captures the creator's full range rather than overrepresenting one topic area.
## Tiptap v3 useEditor requires immediatelyRender: false for React 18
**Context:** Tiptap v3's `useEditor` hook by default attempts synchronous rendering on mount, which conflicts with React 18's concurrent features (StrictMode double-mounting, suspense boundaries). This causes hydration mismatches and "flushSync was called from inside a lifecycle method" warnings.
**Fix:** Pass `immediatelyRender: false` in the useEditor config: `useEditor({ immediatelyRender: false, extensions: [...], content: ... })`. The editor still renders on the first paint — this just defers it to be React 18 compatible. No visual difference.
## get_optional_user pattern for public-but-auth-aware endpoints
**Context:** Some API endpoints need to be publicly accessible but provide different behavior for authenticated users (e.g., showing draft posts to the owner). Using the standard `get_current_user` dependency rejects unauthenticated requests with 401.
**Fix:** Create `get_optional_user` using `OAuth2PasswordBearer(auto_error=False)`. When `auto_error=False`, missing/invalid tokens return `None` instead of raising 401. The endpoint receives `Optional[User]` and branches on whether the user is present and matches the resource owner.
## Pass CSS module styles as Record<string, string> to shared utilities
**Context:** When extracting a shared React utility (e.g., `parseChatCitations`) that renders JSX with CSS module class names, the utility needs the calling component's styles object. TypeScript's `CSSModuleClasses` type is structurally incompatible across module boundaries — passing `styles` typed as the specific module's interface causes type errors.
**Fix:** Type the styles parameter as `Record<string, string>`. CSS modules already satisfy this shape at runtime. This allows any component to pass its own CSS module styles to the shared utility without type gymnastics.
## ffmpeg anullsrc for silent audio in concat workflows
**Context:** When concatenating video segments using ffmpeg's concat demuxer, all segments must have matching stream types. Intro/outro card segments generated with lavfi (color + drawtext) have no audio track, while the main clip has audio. The concat fails with stream mismatch errors.
**Fix:** Add a silent audio track to card segments using `-f lavfi -i anullsrc=r=44100:cl=stereo` and map both video and audio streams. Use `-shortest` to ensure the silent audio matches the card duration. This makes all segments codec-compatible for `-c copy` concat.
## ASS karaoke subtitles from word-level timings
**Context:** Whisper provides word-level timestamps in transcripts. Burning subtitles into short video clips requires converting these to a subtitle format that supports word-by-word highlighting.
**Fix:** Generate ASS (Advanced SubStation Alpha) files with `\k` karaoke tags. Each word gets its own Dialogue line with `{\k<centiseconds>}` duration tag. Clip-relative timing is achieved by subtracting `clip_start` from each word's timestamp. The ffmpeg `ass=` video filter renders the subtitles during encoding. This avoids custom player UI for subtitle rendering entirely.
## Fail-open rate limiting for availability-critical endpoints
**Context:** Redis-based rate limiters can fail when Redis is down or slow. For a chat endpoint that's the product's core feature, blocking all users because the rate limiter is unavailable is worse than allowing a few extra requests through.
**Fix:** Wrap all Redis operations (ZADD, ZREMRANGEBYSCORE, ZCARD) in try/except. On any Redis error, log WARNING with context and return "allowed" — the request proceeds unthrottled. This is a deliberate availability-over-strictness tradeoff. Only appropriate for features where temporary rate-limit bypass is acceptable.
## Sentinel div for IntersectionObserver on sticky/fixed elements
**Context:** IntersectionObserver doesn't detect when a sticky or fixed element scrolls past a threshold, because the element never actually leaves the viewport — it stays stuck in place.
**Fix:** Place an invisible sentinel `<div>` at the position where the event should fire (e.g., just below the article title). Observe the sentinel instead of the sticky element. When the sentinel leaves the viewport, the sticky element should appear. Use a callback ref on the sentinel so the observer reconnects if the target element renders conditionally.

View file

@ -1,131 +0,0 @@
# Chrysopedia
**Knowledge base for music production techniques**, extracted from video content via an LLM-powered pipeline. Producers can search for specific techniques and find timestamped key moments, signal chains, and synthesized study guides — all derived from creator videos.
## Current State
Twenty-five milestones complete. The platform is production-hardened and demo-ready. Rate limiting (Redis sliding-window, 3 tiers), LLM fallback (primary→Ollama), email digest notifications (Celery Beat), GDPR-style data export, creator onboarding wizard, AI transparency page, mobile responsiveness (all pages pass 375px/768px), chat quality toolkit, and formal requirement validation (37/41 validated, 4 out-of-scope). Forgejo wiki at 21 pages with Newcomer Guide. The system is deployed and running on ub01 at `http://ub01:8096`. Forgejo knowledgebase wiki live at `https://git.xpltd.co/xpltdco/chrysopedia/wiki/`.
### What's Built
- **Transcription pipeline** — Desktop script: video → ffmpeg audio extraction → Whisper large-v3 → timestamped transcript JSON
- **6-stage LLM extraction pipeline** — Transcript segmentation → key moment extraction → classification/tagging → technique page synthesis → embedding/indexing. Per-stage model routing (chat vs thinking models). Resumable per-video per-stage.
- **Article versioning** — Pre-overwrite snapshots with pipeline metadata (prompt SHA-256 hashes, model config) for benchmarking extraction quality across prompt iterations.
- **Review queue UI** — Admin interface for approving/editing/rejecting extracted key moments.
- **Search-first web UI** — Dark theme with cyan accents. Semantic search (Qdrant), topic/creator browse pages, technique detail pages with signal chain blocks and video source metadata. Search autocomplete with popular suggestions on focus and typeahead on 2+ characters.
- **Docker Compose deployment** — API, worker, web, watcher, PostgreSQL, Redis, Qdrant, Ollama on ub01 with bind mounts at `/vmPool/r/services/chrysopedia_*`.
- **Transcript folder watcher** — Standalone service monitors `/vmPool/r/services/chrysopedia_watch/` for new transcript JSON files, validates structure, POSTs to ingest API. Processed files move to `processed/`, failures to `failed/` with `.error` sidecar. Uses watchdog PollingObserver for ZFS reliability.
- **Prompt template system** — Editable per-stage prompt files with SHA-256 tracking for reproducibility.
- **Pipeline admin dashboard** — Admin page at `/admin/pipeline` with video list, status badges, retrigger/revoke controls, expandable event log with token usage, collapsible JSON response viewer, and live worker status indicator. Pipeline events instrumented via `PipelineEvent` model (stage, event_type, token counts, JSONB payload).
- **Technique page 2-column layout** — Prose content (summary + study guide) on left, sidebar (key moments, signal chains, plugins, related techniques) on right at desktop widths. Sticky sidebar. Collapses to single column at ≤768px.
- **Key moment card redesign** — Cards in sidebar show title prominently on its own line (h3), with source file, timestamp, and content type badge on a clean metadata row below.
- **Admin navigation dropdown** — Header consolidated: Home, Topics, Creators visible; Admin dropdown reveals Review, Reports, Pipeline. ModeToggle removed from header.
- **Pipeline head/tail log view** — Pipeline event log shows Head/Tail toggle with configurable event count. Token counts visible per event and per video.
- **Pipeline debug mode** — Redis-backed toggle captures full LLM system prompt, user prompt, and response text in pipeline_events. Per-stage token summary endpoint for cost/usage analysis.
- **Debug payload viewer** — Admin UI component for viewing, copying, and exporting full LLM I/O from debug-mode pipeline runs.
- **Admin UX improvements** — Debug mode toggle, status filter pills, cleaner labels, pruned dead UI elements, review queue cross-links.
- **Git commit SHA tracking** — Pipeline captures current git commit SHA. Technique page versions display commit hash in metadata.
- **Technique page tag polish** — Sidebar reordered (Plugins Referenced at top), creator name prominent, tags use coherent color system.
- **Topics page redesign** — 7 categories (including Music Theory) with card layout, descriptions, sub-topic counts, colored left borders.
- **App footer** — Every page shows version, build date, commit SHA (linked to GitHub), and repo link. Build-time constants via Vite define with Docker ARG/ENV passthrough.
- **Key moment search links fixed** — Search results for key moments link to parent technique page with anchor scroll to the specific moment.
- **Test data and jargon cleanup** — Removed TestCreator from production data. Removed yellow "livestream sourced" jargon banner from search results. Clean footer version info.
- **Homepage card enrichment** — Technique cards on homepage show topic tag pills and key moment count. Creator detail pages show technique-count-by-topic instead of meaningless '0 views'.
- **Homepage first impression** — Hero tagline ("Production Knowledge, Distilled"), value proposition, 3-step how-it-works grid, "Start Exploring" CTA, popular topic quick-links, featured technique spotlight (random), and enriched 2-column recently-added grid with deduplication.
- **About page** — /about with what/how/who sections (purpose, 5-step extraction pipeline, maintainer links). Footer link added.
- **Dedicated sub-topic pages** — Clicking a sub-topic on Topics page loads `/topics/:category/:subtopic` with techniques grouped by creator, breadcrumb navigation, and loading/error/empty states.
- **Related techniques cross-linking** — Every technique page shows up to 4 related techniques scored by creator overlap, topic match, and shared tags. Curated links take priority; dynamic results fill remaining slots.
- **Topic color coding & page transitions** — Per-category accent colors on sub-topic pages and search results. CSS-only fade-in page enter animation on all 7 public pages.
- **Search autocomplete** — Shared SearchAutocomplete component with popular suggestions on empty focus and debounced typeahead on 2+ characters. Used on Home and SearchResults pages.
- **Interaction delight** — Card hover animations (scale + shadow), staggered entrance animations on card grids, featured technique glow treatment, Random Technique discovery button.
- **Topics collapse/expand** — Topics page loads with all categories collapsed. Smooth CSS grid-template-rows animation on expand/collapse.
- **Creator stats pills** — Creator detail page topic stats rendered as colored pill badges matching category colors.
- **Tag overflow** — Shared TagList component caps visible tags at 4 with "+N more" overflow pill. Applied across all 5 tag-rendering sites.
- **Empty subtopic handling** — Subtopics with 0 techniques show "Coming soon" badge instead of dead-end links.
- **Accessibility & SEO fixes** — Single h1 per page, skip-to-content keyboard link, AA-compliant muted text contrast (#828291), descriptive per-route browser tab titles via useDocumentTitle hook.
- **Multi-field composite search** — Search tokenizes multi-word queries, AND-matches each token across creator/title/tags/category/body fields. Partial matches fallback when no exact cross-field match exists. Qdrant embeddings enriched with creator names and topic tags. Admin reindex-all endpoint for re-embedding after changes.
- **Sort controls on all list views** — Reusable SortDropdown component on SearchResults, SubTopicPage, and CreatorDetail. Sort options: relevance/newest/oldest/alpha/creator (context-appropriate per page). Preference persists in sessionStorage across navigation.
- **Prompt quality toolkit** — CLI tool (`python -m pipeline.quality`) with: LLM fitness suite (9 tests across Mandelbrot reasoning, JSON compliance, instruction following, diverse battery), 5-dimension quality scorer with voice preservation dial (3-band prompt modification), automated prompt A/B optimization loop (LLM-powered variant generation, iterative scoring, leaderboard/trajectory reporting), multi-stage support for pipeline stages 2-5 with per-stage rubrics and fixtures.
- **Search query logging** — All non-empty searches logged to PostgreSQL search_log table via fire-and-forget async pattern. GET /api/v1/search/popular returns top 10 queries from last 7 days with Redis read-through cache (5-min TTL).
- **Social proof & freshness signals** — Homepage stats scorecard (article count, creator count), trending searches pill section, and date stamps on recently-added cards. Creators browse page shows "Last updated" per creator. Admin dropdown opens on hover at desktop widths.
- **Multi-source technique pages** — Technique pages restructured to support multiple source videos per page. Nested H2/H3 body sections with table of contents and inline [N] citation markers linking prose claims to source key moments. Composition pipeline merges new video moments into existing pages with offset-based citation re-indexing and deduplication. Format-discriminated rendering (v1 dict / v2 list-of-objects) preserves backward compatibility. Per-section Qdrant embeddings with deterministic UUIDs enable section-level search results with deep-link scrolling. Admin view at /admin/techniques for multi-source page management.
- **Brand identity baseline** — Custom favicon (SVG + PNG fallback), apple-touch-icon, OG/Twitter social meta tags for sharing previews, inline SVG logo mark (cyan arc + dot) in header. Programmatic PNG generation via Python stdlib.
- **Modern Table of Contents** — Technique page sidebar ToC with left accent bar, "On this page" heading, IntersectionObserver scroll-spy active section highlighting. V2-format pages only.
- **Sticky reading header** — Thin bar slides in when article title scrolls out of viewport, shows technique title + current section name. Mobile-responsive (hides section at <600px).
- **Homepage personality polish** — Animated stat count-up (useCountUp hook with rAF + ease-out), unified .section-heading utility class, tighter above-fold layout, header brand accent line.
- **Pipeline admin UI fixes** — Collapse toggle styling, mobile card layout, stage chevrons, filter right-alignment, creator dropdown visibility.
- **Creator profile page** — Hero section (96px avatar, bio, genre pills), social link icons (9 platforms), stats dashboard (technique/video/moment counts), featured technique card with gradient border, enriched technique grid (summary, tags, moment count), inline admin editing (bio + social links), and 480px mobile responsive overrides.
- **Post editor + file sharing** — Tiptap v3 rich text editor with formatting toolbar, drag-and-drop file attachments stored in MinIO (internal-only, presigned URL downloads). Post CRUD API with creator ownership, PostsFeed on creator profiles, PostsList management page.
- **Shorts generation pipeline** — ffmpeg-based clip extraction from approved highlights in 3 format presets (vertical 1080×1920, square 1080×1080, horizontal 1920×1080). GeneratedShort model, Celery task with per-preset independent error handling, MinIO storage, HighlightQueue UI with generate/status/download.
- **5-tier personality interpolation** — Personality slider (0.01.0) threads through API to ChatService. Progressive field inclusion: <0.2 pure encyclopedic, 0.2+ basic tone, 0.4+ descriptors, 0.6+ signature phrases (count scaled), 0.8+ vocabulary markers, 0.9+ summary paragraph. Temperature 0.30.5 linear.
- **Shorts publishing flow** — Public shareable URLs via token-based access (`/shorts/:token`). ShortPlayer page renders video with creator metadata. Share link and embed code copy buttons on HighlightQueue. Public API endpoint resolves share_token with no auth.
- **Auto-captioning** — Whisper-generated ASS karaoke subtitles with per-word `\k` tags burned into shorts via ffmpeg ass filter. Non-blocking: caption failure doesn't block short generation. 17 unit tests.
- **Shorts template system** — Creator-configurable intro/outro cards via JSONB template config on Creator model. ffmpeg lavfi card rendering with concat demuxer pipeline. Admin API and collapsible config panel in HighlightQueue. Silent audio tracks for codec-compatible concat. 28 unit tests.
- **Embeddable player** — Chrome-free player at `/embed/:videoId` rendered outside AppShell. Audio-aware iframe snippet generation (120px audio, 405px video). Copy Embed Code button on WatchPage.
- **Key moment timeline pins** — 12px color-coded circle pins on player seek bar (technique=cyan, settings=amber, reasoning=purple, workflow=green) with active-state highlighting and touch-friendly hit areas. Collapsible inline player on TechniquePage with chapter pins and bibliography seek wiring.
- **Citation timestamp links** — Chat source cards show timestamp badges linking to `/watch/:id?t=N`. Video metadata (source_video_id, start_time, end_time, filename) propagated through search → chat → SSE → frontend. Shared parseChatCitations and formatTime utilities.
- **Creator authentication** — Invite-code registration, JWT login (HS256, 24h expiry), protected routes. User and InviteCode models with Alembic migration 016. FastAPI auth router (register, login, me, update-profile). React AuthContext with localStorage JWT persistence.
- **Creator dashboard shell** — Protected /creator/* routes with sidebar nav (Dashboard, Settings). Profile edit and password change forms. Code-split with React.lazy.
- **Consent infrastructure** — Per-video consent toggles (allow_embed, allow_search, allow_kb, allow_download, allow_remix) with versioned audit trail. VideoConsent and ConsentAuditLog models with Alembic migration 017. 5 API endpoints with ownership verification and admin bypass.
- **Web media player** — Custom video player page at `/watch/:videoId` with HLS playback (lazy-loaded hls.js), speed controls (0.52x), volume, seek, fullscreen, keyboard shortcuts, and synchronized transcript sidebar with binary search active segment detection and auto-scroll. Technique page key moment timestamps link directly to the watch page. Video + transcript API endpoints with creator info.
- **LightRAG graph-enhanced retrieval** — Running as chrysopedia-lightrag service on port 9621. Uses DGX Sparks for LLM (entity extraction, summarization), Ollama nomic-embed-text for embeddings, Qdrant for vector storage, NetworkX for graph storage. 12 music production entity types configured. Exposed via REST API at /documents/text (ingest) and /query (retrieval with local/global/mix/hybrid modes).
- **Modular API client** — Frontend API layer split from single 945-line file into 10 domain modules (client.ts, search.ts, techniques.ts, creators.ts, topics.ts, stats.ts, reports.ts, admin-pipeline.ts, admin-techniques.ts, auth.ts) with shared request helper and barrel index.ts.
- **Site Audit Report** — 467-line comprehensive reference document mapping all 12 routes, 41 API endpoints, 13 data models, CSS architecture (77 custom properties), and 8 Phase 2 integration risks. Lives at `.gsd/milestones/M018/slices/S01/SITE-AUDIT-REPORT.md`.
- **Forgejo knowledgebase wiki** — 19-page architecture documentation at `https://git.xpltd.co/xpltdco/chrysopedia/wiki/` covering Architecture, Data Model, API Surface, Frontend, Pipeline, Deployment, Development Guide, Decisions, Chat Engine, Search & Retrieval, and Highlights.
- **LightRAG primary search** — LightRAG /query/data is primary search engine with automatic Qdrant+keyword fallback on failure/timeout/empty. Position-based scoring for results. Structured logging with fallback_used flag.
- **Creator-scoped retrieval cascade** — 4-tier cascade (creator → domain → global → none) narrows search context by creator profile. Uses ll_keywords for soft scoping and post-filtering for strict creator match. cascade_tier response field for downstream consumers.
- **Streaming chat engine** — POST /api/v1/chat with SSE streaming (sources → token* → done|error events). Encyclopedic LLM prompting with numbered citations linking to technique pages. Dark-themed ChatPage with real-time token display at /chat.
- **Highlight detection v1** — Heuristic scoring engine with 7 weighted dimensions (duration fitness, content type, specificity density, plugin richness, transcript energy, source quality, video type) scores KeyMoment data into ranked highlight candidates stored in `highlight_candidates` table. Celery task for batch processing, 4 admin API endpoints for triggering detection and listing/inspecting candidates. 28 unit tests.
- **Audio mode + chapter markers** — WatchPage conditionally renders AudioWaveform (wavesurfer.js) or VideoPlayer. ChapterMarkers overlay tick buttons on seek bar. useMediaSync widened for audio/video polymorphism. Backend stream and chapters endpoints.
- **Chapter review UI** — Creator-facing ChapterReview page at /creator/chapters/:videoId with waveform regions (draggable/resizable), status cycling (draft→approved→hidden), rename, reorder. 4 chapter management API endpoints.
- **Impersonation write mode** — write_mode support on impersonation tokens with ConfirmModal confirmation. ImpersonationBanner shows during sessions. AdminAuditLog page at /admin/audit-log with paginated session history.
- **Highlight review queue** — Creator-scoped HighlightQueue page at /creator/highlights with filter tabs (All/Shorts/Approved/Rejected), score breakdown bars, approve/discard actions, inline trim panel. 4 backend endpoints with ownership verification.
- **Follow system** — CreatorFollow model with idempotent follow/unfollow (INSERT ON CONFLICT DO NOTHING), follower_count on creator detail, follow button on CreatorDetail hero. CreatorTiers page with Free/Pro/Premium cards and Coming Soon modals.
- **Chat widget** — Floating ChatWidget on creator profile pages: fixed-position bubble → slide-up panel, streaming SSE scoped to creator, typing indicator, citation links, suggested questions from technique data.
- **Multi-turn conversation memory** — Redis-backed conversation history with conversation_id threading (API → ChatService → SSE done event → frontend). 10 turn pair cap, 1h TTL. Both ChatWidget and ChatPage support multi-turn.
- **Highlight detection v2** — 10-dimension scoring with 3 audio proxy dimensions (speech_rate_variance, pause_density, speaking_pace) from word-level transcript timing. Neutral fallback (0.5) when word timings unavailable. 62 tests.
- **Personality profile extraction** — Creator.personality_profile JSONB column, 3-tier transcript sampling with topic-diverse selection, LLM extraction with Pydantic validation, admin trigger endpoint, collapsible frontend component.
### Stack
- **Backend:** FastAPI + Celery + SQLAlchemy (async) + PostgreSQL + Redis + Qdrant
- **Frontend:** React + TypeScript + Vite
- **LLM:** OpenAI-compatible API (DGX Sparks Qwen primary, local Ollama fallback)
- **Deployment:** Docker Compose on ub01, nginx reverse proxy on nuc01
### Milestone History
| ID | Title | Status |
|----|-------|--------|
| M001 | Chrysopedia Foundation — Infrastructure, Pipeline Core, and Skeleton UI | ✅ Complete |
| M002 | Deployment — GitHub, ub01 Docker Stack, and Production Wiring | ✅ Complete |
| M003 | Domain + DNS + Per-Stage LLM Model Routing | ✅ Complete |
| M004 | UI Polish, Bug Fixes, Technique Page Redesign, and Article Versioning | ✅ Complete |
| M005 | Pipeline Dashboard, Technique Page Redesign, Key Moment Cards | ✅ Complete |
| M006 | Admin Nav, Pipeline Log Views, Commit SHA, Tag Polish, Topics Redesign, Footer | ✅ Complete |
| M007 | Pipeline Transparency, Auto-Ingest, Admin UX Polish, and Mobile Fixes | ✅ Complete |
| M008 | Credibility Debt Cleanup — Broken Links, Test Data, Jargon, Empty Metrics | ✅ Complete |
| M009 | Homepage & First Impression | ✅ Complete |
| M010 | Discovery, Navigation & Visual Identity | ✅ Complete |
| M011 | Interaction Polish, Navigation & Accessibility | ✅ Complete |
| M012 | Multi-Field Composite Search & Sort Controls | ✅ Complete |
| M013 | Prompt Quality Toolkit — LLM Fitness, Scoring, and Automated Optimization | ✅ Complete |
| M014 | Multi-Source Technique Pages — Nested Sections, Composition, Citations, and Section Search | ✅ Complete |
| M015 | Social Proof, Freshness Signals & Admin UX | ✅ Complete |
| M016 | Visual Identity & Reading Experience | ✅ Complete |
| M017 | Creator Profile Page — Hero, Stats, Featured Technique & Admin Editing | ✅ Complete |
| M018 | Phase 2 Research & Documentation — Site Audit and Forgejo Wiki Bootstrap | ✅ Complete |
| M019 | Foundations — Auth, Consent & LightRAG | ✅ Complete |
| M020 | Core Experiences — Player, Impersonation & Knowledge Routing | ✅ Complete |
| M021 | Intelligence Online — Chat, Chapters & Search Cutover | ✅ Complete |
| M022 | Creator Tools & Personality | ✅ Complete |
| M023 | MVP Integration — Demo Build | ✅ Complete |
| M024 | Polish, Shorts Pipeline & Citations | ✅ Complete |
| M025 | Hardening & Launch Prep | ✅ Complete |

View file

@ -1,252 +0,0 @@
# Requirements
## R001 — Whisper Transcription Pipeline
**Status:** validated
**Description:** Desktop Python script that accepts video files (MP4/MKV), extracts audio via ffmpeg, runs Whisper large-v3 on RTX 4090, and outputs timestamped transcript JSON with segment-level timestamps and word-level timing. Must be resumable.
**Validation:** Script processes a sample video and produces valid JSON with timestamped segments.
**Primary Owner:** M001/S01
## R002 — Transcript Ingestion API
**Status:** validated
**Description:** FastAPI endpoint that accepts transcript JSON uploads, creates/updates Creator and Source Video records, and stores transcript data in PostgreSQL. Handles new creator detection from folder names.
**Validation:** POST transcript JSON → 200 OK, records created in DB, file stored on filesystem.
**Primary Owner:** M001/S02
## R003 — LLM-Powered Extraction Pipeline (Stages 2-5)
**Status:** validated
**Description:** Background worker pipeline: transcript segmentation → key moment extraction → classification/tagging → technique page synthesis. Uses OpenAI-compatible API with primary (DGX Sparks Qwen) and fallback (local Ollama) endpoints. Pipeline must be resumable per-video per-stage.
**Validation:** End-to-end: transcript JSON in → technique pages with key moments, tags, and cross-references out.
**Primary Owner:** M001/S03
## R004 — Review Queue UI
**Status:** validated
**Description:** Admin interface for reviewing extracted key moments: approve, edit+approve, split, merge, reject. Organized by source video for contextual review. Includes mode toggle (review vs auto-publish).
**Validation:** Admin can review, edit, and approve/reject moments; mode toggle controls whether new moments require review.
**Primary Owner:** M001/S04
## R005 — Search-First Web UI
**Status:** validated
**Description:** Landing page with prominent search bar, live typeahead (results after 2-3 chars), scope toggle (All/Topics/Creators), and two navigation cards (Topics, Creators). Recently added section. Search powered by Qdrant semantic search with keyword fallback.
**Validation:** User types query → results appear within 500ms, grouped by type, with clickable navigation.
**Primary Owner:** M001/S05
## R006 — Technique Page Display
**Status:** validated
**Description:** Core content unit: header (tags, title, creator, meta), study guide prose (organized by sub-aspects with signal chain blocks and quotes), key moments index (timestamped list), related techniques, plugins referenced. Amber banner for livestream-sourced content.
**Validation:** Technique page renders with all sections populated from synthesized data.
**Primary Owner:** M001/S05
## R007 — Creators Browse Page
**Status:** validated
**Description:** Filterable creator list with genre filter pills, type-to-narrow, sort options (randomized default, alphabetical, view count). Each row: name, genre tags, technique count, video count, view count. Links to creator detail page.
**Validation:** Page loads with randomized order, genre filtering works, clicking row navigates to creator detail.
**Primary Owner:** M001/S05
## R008 — Topics Browse Page
**Status:** validated
**Description:** Two-level topic hierarchy (7 top-level categories → sub-topics). Filter input, genre filter pills. Each sub-topic shows technique count and creator count. Clicking sub-topic loads a dedicated page with creator-grouped techniques and breadcrumbs.
**Validation:** Hierarchy renders, filtering works, sub-topics have dedicated pages at /topics/:category/:subtopic with backend filtering and creator grouping. Verified: M010/S01.
**Primary Owner:** M001/S05
## R009 — Qdrant Vector Search Integration
**Status:** validated
**Description:** Embed key moment summaries, technique page content, and transcript segments in Qdrant using configurable embedding model (nomic-embed-text default). Power semantic search with metadata filtering.
**Validation:** Semantic search returns relevant results for natural language queries; embeddings update when content changes.
**Primary Owner:** M001/S03
## R010 — Docker Compose Deployment
**Status:** validated
**Description:** Single docker-compose.yml packaging API, web UI, PostgreSQL, and worker services. Follows XPLTD conventions: bind mounts at /vmPool/r/services/, compose at /vmPool/r/compose/chrysopedia/, xpltd_chrysopedia project name, dedicated Docker network.
**Validation:** `docker compose up -d` brings up all services; data persists across restarts.
**Primary Owner:** M001/S01
## R011 — Canonical Tag System
**Status:** validated
**Description:** Editable canonical tag list (config file) with aliases. Pipeline references tags during classification. New tags can be proposed by LLM and queued for admin approval or auto-added within existing categories.
**Validation:** Tag list is editable; pipeline uses canonical tags consistently; alias normalization works.
**Primary Owner:** M001/S03
## R012 — Incremental Content Addition
**Status:** validated
**Description:** System handles ongoing content: new videos processed through pipeline, new creators auto-detected, existing technique pages updated when new moments are added for same creator+topic.
**Validation:** Adding a new video for an existing creator updates their technique pages; new creator folder creates new Creator record.
**Primary Owner:** M001/S03
## R013 — Prompt Template System
**Status:** validated
**Description:** Extraction prompts (stages 2-5) stored as editable configuration files, not hardcoded. Admin can edit prompts and re-run extraction on specific or all videos for calibration.
**Validation:** Prompt files are editable; re-processing a video with updated prompts produces different output.
**Primary Owner:** M001/S03
## R014 — Creator Equity
**Status:** validated
**Description:** No creator is privileged in the UI. Default sort on Creators page is randomized on every page load. All creators get equal visual weight.
**Validation:** Refreshing Creators page shows different order each time; no creator gets larger/bolder display.
**Primary Owner:** M001/S05
## R015 — 30-Second Retrieval Target
**Status:** validated
**Description:** A producer mid-session can find a specific technique in under 30 seconds from Alt+Tab to reading the key insight.
**Validation:** Timed browser test: homepage → search "snare" → click first result → technique page content visible in ~9 seconds (well under 30s target). Verified on live deployment at ub01:8096.
**Primary Owner:** M001/S05
## R016 — Card Hover Animations & Staggered Entrance
**Status:** validated
**Description:** All technique/content cards animate on hover (scale + shadow transition). Card grids use staggered fade-in-up entrance animations on page load.
**Validation:** Cards visually respond to hover with smooth 200ms transition. Grid cards appear sequentially on page load.
**Primary Owner:** M011/S01
## R017 — Featured Technique Visual Redesign
**Status:** validated
**Description:** Featured Technique section on homepage has a visually distinct treatment (gradient border, glow, or full-bleed) that differentiates it from ordinary cards.
**Validation:** Featured technique is visually prominent and clearly distinct from regular technique cards.
**Primary Owner:** M011/S01
## R018 — Random Technique Discovery
**Status:** validated
**Description:** A "Random Technique" button exists that navigates to a randomly selected technique page.
**Validation:** Clicking the button loads a different technique each time.
**Primary Owner:** M011/S01
## R019 — Topics Page Default-Collapsed
**Status:** validated
**Description:** Topics page loads with all categories collapsed, showing only category cards. Clicking a category expands its sub-topics with a smooth slide animation.
**Validation:** Page loads collapsed. Click expands with animation. Click again collapses.
**Primary Owner:** M011/S02
## R020 — Global Search in Navigation
**Status:** validated
**Description:** A compact search input in the navigation bar is available on all pages except the homepage. Cmd+K (or /) keyboard shortcut focuses it from any page.
**Validation:** Search bar visible in nav on Topics, Creators, Technique, About pages. Cmd+K focuses it.
**Primary Owner:** M011/S03
## R021 — Mobile Hamburger Menu
**Status:** validated
**Description:** Navigation uses a hamburger menu on viewports below 768px with generous touch targets (44×44px minimum).
**Validation:** Mobile viewport shows hamburger icon. Tapping reveals nav items. Touch targets meet minimum size.
**Primary Owner:** M011/S03
## R022 — Heading Hierarchy Fix
**Status:** validated
**Description:** Every page has exactly one H1 element. Heading levels are sequential (no skips from H1 to H3).
**Validation:** DOM inspection confirms single H1 per page and sequential heading levels.
**Primary Owner:** M011/S04
## R023 — Skip-to-Content Link
**Status:** validated
**Description:** A visually hidden skip link is the first focusable element on every page, visible on keyboard focus.
**Validation:** Tab from page load shows "Skip to content" link. Clicking it jumps to main content area.
**Primary Owner:** M011/S04
## R024 — Text Contrast AA Compliance
**Status:** validated
**Description:** All text meets WCAG AA contrast ratios (4.5:1 for normal text, 3:1 for large text) against the dark background.
**Validation:** Secondary text color passes 4.5:1 contrast check against page background.
**Primary Owner:** M011/S04
## R025 — Page-Specific Document Titles
**Status:** validated
**Description:** Each route sets a descriptive document title (e.g., "Bass — Sound Design — Chrysopedia", "COPYCATT — Chrysopedia").
**Validation:** Browser tab shows distinct title per page. Navigating between pages updates the title.
**Primary Owner:** M011/S04
## R026 — Creator Stats Topic-Colored Pills
**Status:** validated
**Description:** Creator detail page stats line rendered as topic-colored pill badges instead of run-on text.
**Validation:** Stats show as visually distinct colored pills grouped by topic category.
**Primary Owner:** M011/S02
## R027 — Tag Overflow Limit on Cards
**Status:** validated
**Description:** Technique cards show a maximum of 4 tags, with a "+N more" indicator for additional tags.
**Validation:** Cards with >4 tags show exactly 4 plus a "+N more" badge.
**Primary Owner:** M011/S02
## R028 — Empty Subtopic Handling
**Status:** validated
**Description:** Sub-topics with 0 techniques either hidden by default or display a "Coming soon" badge.
**Validation:** Empty subtopics are visually distinguished or hidden. No dead-end clicks.
**Primary Owner:** M011/S02
## Denied / Out of Scope
## R029 — Beginner Learning Paths
**Status:** out-of-scope
**Description:** Curated beginner learning paths with skill-level tags and "New to Production?" section.
**Validation:** n/a
**Notes:** Denied — audience knows what they're looking for. Assessment F01.
## R030 — YouTube Deep Links on Key Moments
**Status:** out-of-scope
**Description:** Key moment timestamps link to source YouTube videos at the correct time.
**Validation:** n/a
**Notes:** Denied — no video URLs in data model / don't want to link out. Assessment F02.
## R031 — Hide Admin Dropdown
**Status:** out-of-scope
**Description:** Admin dropdown hidden from public visitors behind auth or flag.
**Validation:** n/a
**Notes:** Denied — fine as-is. Assessment F03.
## R032 — CTA Label Changes
**Status:** out-of-scope
**Description:** Change "Start Exploring" to more descriptive CTA text.
**Validation:** n/a
**Notes:** Denied — skipped. Assessment F15.
## R033 — Creator Last Updated Date
**Status:** validated
**Description:** Each creator shows when their latest technique page was added. Visible on Creators browse page and subtly on homepage recently-added cards.
**Validation:** Creators browse page shows "Last updated: Apr 2/3" per creator. Homepage recently-added cards show subtle date stamps. Both verified live on ub01:8096 via browser assertions.
**Primary Owner:** M015/S02
## R034 — Homepage Stats Scorecard
**Status:** validated
**Description:** Homepage displays a visual metrics block showing article count, creator count in a scorecard/metric style that communicates volume and credibility without being literal or boastful.
**Validation:** GET /api/v1/stats returns {technique_count:21, creator_count:7}. Homepage scorecard displays "21 ARTICLES" and "7 CREATORS" in cyan-on-dark design. Verified via curl and browser screenshot on ub01:8096.
**Primary Owner:** M015/S03
## R035 — Popular Search Terms Display
**Status:** validated
**Description:** Show real search terms users are using in real-time. Backend logs all search queries to PostgreSQL, caches popular queries in Redis, and exposes a trending endpoint. Frontend displays these on the homepage.
**Validation:** Backend: search_log table with real rows, GET /search/popular returns cached top-N (5-min Redis TTL). Frontend: Trending Searches homepage section with clickable pill links. End-to-end verified on ub01:8096.
**Primary Owner:** M015/S01
**Supporting Slices:** M015/S04
## R036 — Admin Dropdown Hover on Desktop
**Status:** validated
**Description:** Admin navigation dropdown opens on mouse hover at desktop viewport widths (≥768px). On mobile/touch, remains click/tap to expand.
**Validation:** AdminDropdown.tsx implements matchMedia(min-width:769px) guard with 150ms leave delay. Mobile retains tap-to-toggle. Frontend build passes with zero errors.
**Primary Owner:** M015/S05
## R037 — Landing Page Visual Consistency
**Status:** validated
**Description:** Homepage has consistent max-width tracks, spacing, border-radius, and no CSS bugs (duplicate .btn rule, border-image killing border-radius). Stats scorecard has animated count-up. Section headings use unified treatment.
**Validation:** Visual comparison at 1280px: 3-column card alignment, stats scorecard (95 ARTICLES, 26 CREATORS) with count-up animation, consistent spacing. At 375px: single-column, no overflow or breakage. Verified on ub01:8096 via browser screenshots.
**Primary Owner:** M016/S01
**Supporting Slices:** M016/S06
## R038 — Pipeline Admin UI Fixes
**Status:** validated
**Description:** Pipeline admin page: most-recent run collapse toggle works without flicker, mobile job cards don't show vertical text, stage direction chevrons visible, status filter uses button group (not text input), creator dropdown populates without 422 error.
**Validation:** Pipeline admin at ub01:8096/admin/pipeline: collapse toggle on Recent Activity works (click toggles). Text filter shows multiple creators (KOAN Sound, COPYCATT, Current Value, DJ Shortee, DONKONG, Chee). Mobile 375px: no vertical text on cards, single-column layout.
**Primary Owner:** M016/S02
## R039 — Brand Minimum (Favicon, OG Tags, Logo)
**Status:** validated
**Description:** Site has a favicon, OG meta tags for social sharing previews, and an inline SVG logo next to "Chrysopedia" in the header.
**Validation:** DOM: link[rel='icon'] href="/favicon.svg", meta[property='og:title'] content="Chrysopedia", meta[property='og:image'] content="/og-image.png". Logo SVG visible in header. HTTP 200 for /favicon.svg and /og-image.png on ub01:8096.
**Primary Owner:** M016/S03
## R040 — Table of Contents Modernization
**Status:** validated
**Description:** Technique page ToC uses clean indentation (no numbered counters), left accent bar, "On this page" heading, hover background states, IntersectionObserver-based active section highlighting, and sticky positioning in sidebar.
**Validation:** Navigate to a 4+ section technique page. ToC highlights current section on scroll. Sticky in sidebar. No numbering. Hover states work.
**Primary Owner:** M016/S04
## R041 — Sticky Reading Header
**Status:** validated
**Description:** Thin sticky bar appears when user scrolls past the article title on technique pages. Shows article title (truncated) + current section name. Slides in/out with CSS transition. Works on mobile.
**Validation:** Navigated to v2 technique page (drop-first-production-copycatt). Scrolled past title: .reading-header--visible class present, showing truncated title. At 375px mobile: reading header renders at full width. CSS transition slide-in/out confirmed.
**Primary Owner:** M016/S05
**Notes:** ReadingHeader only renders on v2 body_sections_format pages (84 of 95 pages). The 11 v1 pages are legacy format from early pipeline runs.

View file

@ -1,42 +0,0 @@
# GSD State
**Active Milestone:** M025: Hardening & Launch Prep
**Active Slice:** None
**Phase:** complete
**Requirements Status:** 0 active · 0 validated · 0 deferred · 0 out of scope
## Milestone Registry
- ✅ **M001:** Chrysopedia Foundation - Infrastructure, Pipeline Core, and Skeleton UI
- ✅ **M002:** M002:
- ✅ **M003:** M003:
- ✅ **M004:** M004:
- ✅ **M005:** M005:
- ✅ **M006:** M006:
- ✅ **M007:** M007:
- ✅ **M008:** M008:
- ✅ **M009:** Homepage & First Impression
- ✅ **M010:** Discovery, Navigation & Visual Identity
- ✅ **M011:** M011:
- ✅ **M012:** M012:
- ✅ **M013:** M013:
- ✅ **M014:** M014:
- ✅ **M015:** M015:
- ✅ **M016:** M016:
- ✅ **M017:** M017:
- ✅ **M018:** M018:
- ✅ **M019:** Foundations — Auth, Consent & LightRAG
- ✅ **M020:** Core Experiences — Player, Impersonation & Knowledge Routing
- ✅ **M021:** Intelligence Online — Chat, Chapters & Search Cutover
- ✅ **M022:** Creator Tools & Personality
- ✅ **M023:** MVP Integration — Demo Build
- ✅ **M024:** Polish, Shorts Pipeline & Citations
- ✅ **M025:** Hardening & Launch Prep
## Recent Decisions
- None recorded
## Blockers
- None
## Next Action
All milestones complete.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show more