From 44c0df6e0842b53064f80108d386151158773788 Mon Sep 17 00:00:00 2001
From: jlightner
Date: Mon, 30 Mar 2026 19:34:11 +0000
Subject: [PATCH] =?UTF-8?q?feat:=20Added=20DebugModeToggle=20component=20a?=
=?UTF-8?q?nd=20StatusFilter=20pill=20bar=20to=20Admi=E2=80=A6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- "frontend/src/pages/AdminPipeline.tsx"
- "frontend/src/api/public-client.ts"
- "frontend/src/App.css"
GSD-Task: S04/T01
---
.gsd/KNOWLEDGE.md | 6 +
.gsd/PROJECT.md | 3 +-
.gsd/milestones/M007/M007-ROADMAP.md | 2 +-
.../milestones/M007/slices/S03/S03-SUMMARY.md | 93 ++++++++++++++++
.gsd/milestones/M007/slices/S03/S03-UAT.md | 97 ++++++++++++++++
.../M007/slices/S03/tasks/T02-VERIFY.json | 9 ++
.gsd/milestones/M007/slices/S04/S04-PLAN.md | 25 ++++-
.../M007/slices/S04/S04-RESEARCH.md | 101 +++++++++++++++++
.../M007/slices/S04/tasks/T01-PLAN.md | 33 ++++++
.../M007/slices/S04/tasks/T01-SUMMARY.md | 79 +++++++++++++
.../M007/slices/S04/tasks/T02-PLAN.md | 31 ++++++
frontend/src/App.css | 51 +++++++++
frontend/src/api/public-client.ts | 17 +++
frontend/src/pages/AdminPipeline.tsx | 104 +++++++++++++++++-
14 files changed, 646 insertions(+), 5 deletions(-)
create mode 100644 .gsd/milestones/M007/slices/S03/S03-SUMMARY.md
create mode 100644 .gsd/milestones/M007/slices/S03/S03-UAT.md
create mode 100644 .gsd/milestones/M007/slices/S03/tasks/T02-VERIFY.json
create mode 100644 .gsd/milestones/M007/slices/S04/S04-RESEARCH.md
create mode 100644 .gsd/milestones/M007/slices/S04/tasks/T01-PLAN.md
create mode 100644 .gsd/milestones/M007/slices/S04/tasks/T01-SUMMARY.md
create mode 100644 .gsd/milestones/M007/slices/S04/tasks/T02-PLAN.md
diff --git a/.gsd/KNOWLEDGE.md b/.gsd/KNOWLEDGE.md
index f875c07..c0da99e 100644
--- a/.gsd/KNOWLEDGE.md
+++ b/.gsd/KNOWLEDGE.md
@@ -1,5 +1,11 @@
# 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".
diff --git a/.gsd/PROJECT.md b/.gsd/PROJECT.md
index eed90cf..b9dcfde 100644
--- a/.gsd/PROJECT.md
+++ b/.gsd/PROJECT.md
@@ -13,7 +13,8 @@ Five milestones complete plus a sixth refinement milestone. The system is deploy
- **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.
-- **Docker Compose deployment** — API, worker, web, PostgreSQL, Redis, Qdrant, Ollama on ub01 with bind mounts at `/vmPool/r/services/chrysopedia_*`.
+- **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.
diff --git a/.gsd/milestones/M007/M007-ROADMAP.md b/.gsd/milestones/M007/M007-ROADMAP.md
index fd4e585..fb56c0a 100644
--- a/.gsd/milestones/M007/M007-ROADMAP.md
+++ b/.gsd/milestones/M007/M007-ROADMAP.md
@@ -8,7 +8,7 @@ Make the pipeline fully transparent — every LLM call's full prompt and respons
|----|-------|------|---------|------|------------|
| S01 | Pipeline Debug Mode — Full LLM I/O Capture and Token Accounting | medium | — | ✅ | Toggle debug mode on for a video, trigger pipeline, see full prompt + response stored for every LLM call with per-stage token breakdown |
| S02 | Debug Payload Viewer — Inline View, Copy, and Export in Admin UI | low | S01 | ✅ | Admin clicks an LLM call event, sees full prompt and response in a readable viewer, copies to clipboard, or downloads as JSON |
-| S03 | Transcript Folder Watcher — Auto-Ingest Service | medium | — | ⬜ | Drop a transcript JSON into the watch folder on ub01 → file is detected, validated, POSTed to /ingest, pipeline triggers automatically |
+| S03 | Transcript Folder Watcher — Auto-Ingest Service | medium | — | ✅ | Drop a transcript JSON into the watch folder on ub01 → file is detected, validated, POSTed to /ingest, pipeline triggers automatically |
| S04 | Admin UX Audit — Prune, Streamline, and Polish | low | S02 | ⬜ | Admin pipeline page is cleaner and more efficient for daily content management. Dead UI elements removed. Workflow improvements visible. |
| S05 | Key Moment Card Text Overflow Fix | low | — | ⬜ | Key moment cards with long filenames and timestamp ranges display cleanly — text truncated with ellipsis or wrapped within card bounds, no horizontal bleed |
| S06 | Mobile Viewport Overflow Fix — Technique Pages and Global Content | low | S05 | ⬜ | Technique page on Galaxy S25 Ultra renders cleanly — body text wraps, tags don't overflow, metadata stays within viewport, no horizontal scroll |
diff --git a/.gsd/milestones/M007/slices/S03/S03-SUMMARY.md b/.gsd/milestones/M007/slices/S03/S03-SUMMARY.md
new file mode 100644
index 0000000..c81b2a5
--- /dev/null
+++ b/.gsd/milestones/M007/slices/S03/S03-SUMMARY.md
@@ -0,0 +1,93 @@
+---
+id: S03
+parent: M007
+milestone: M007
+provides:
+ - chrysopedia-watcher Docker service running on ub01
+ - Auto-ingest via file drop to /vmPool/r/services/chrysopedia_watch/
+ - backend/watcher.py standalone script
+requires:
+ []
+affects:
+ []
+key_files:
+ - backend/watcher.py
+ - backend/requirements.txt
+ - docker-compose.yml
+key_decisions:
+ - Used httpx sync client in watcher (runs in synchronous watchdog callback threads)
+ - PollingObserver over inotify Observer for ZFS/NFS reliability
+ - os.kill(1,0) healthcheck instead of pgrep for slim Python images
+ - SCP'd files to ub01 directly instead of git push to avoid outward-facing GitHub action
+ - Ignored files in processed/ and failed/ subdirs to prevent re-processing loops
+patterns_established:
+ - Standalone watcher service pattern: watchdog PollingObserver + file stability check + validate + POST + disposition (processed/failed with error sidecar)
+ - Reusing Dockerfile.api with command override for lightweight companion services
+observability_surfaces:
+ - docker logs chrysopedia-watcher — shows all watcher activity (pickup, stability wait, validation, POST result, file moves)
+ - .error sidecar files in failed/ directory contain HTTP status, response body, or exception traceback
+drill_down_paths:
+ - .gsd/milestones/M007/slices/S03/tasks/T01-SUMMARY.md
+ - .gsd/milestones/M007/slices/S03/tasks/T02-SUMMARY.md
+duration: ""
+verification_result: passed
+completed_at: 2026-03-30T19:26:20.063Z
+blocker_discovered: false
+---
+
+# S03: Transcript Folder Watcher — Auto-Ingest Service
+
+**Built and deployed a watchdog-based folder watcher service that auto-ingests transcript JSON files dropped into a monitored directory on ub01, replacing manual curl/upload for pipeline input.**
+
+## What Happened
+
+This slice delivered a new Docker service (`chrysopedia-watcher`) that monitors `/vmPool/r/services/chrysopedia_watch/` on ub01 for new transcript JSON files and automatically POSTs them to the ingest API.
+
+**T01** built `backend/watcher.py` — a standalone Python script using watchdog's `PollingObserver` (chosen over inotify for ZFS/NFS reliability). The watcher handles file stability detection (waits for size to stabilize over 2 seconds to handle partial SCP/rsync writes), validates JSON structure against required keys (`source_file`, `creator_folder`, `duration_seconds`, `segments`), POSTs valid files as multipart upload via httpx to `/api/v1/ingest`, and moves files to `processed/` or `failed/` subdirectories. Failed files get an `.error` sidecar with the failure details (HTTP status, response body, or exception traceback). Files inside `processed/` and `failed/` subdirectories are ignored to prevent re-processing loops.
+
+**T02** wired the watcher into the Docker Compose stack as `chrysopedia-watcher`, reusing the existing `Dockerfile.api` image with a command override. The healthcheck was changed from `pgrep` (unavailable in slim Python image) to `python -c "import os; os.kill(1, 0)"`. Deployed to ub01 and verified end-to-end: valid transcript JSON auto-ingested (HTTP 200, file moved to `processed/`), invalid JSON moved to `failed/` with `.error` sidecar.
+
+## Verification
+
+All slice-level verification checks pass:
+1. `python -m py_compile backend/watcher.py` — exits 0
+2. `grep -q 'watchdog' backend/requirements.txt` — exits 0
+3. `grep -q 'PollingObserver' backend/watcher.py` — exits 0
+4. `grep -q 'processed' backend/watcher.py && grep -q 'failed' backend/watcher.py` — exits 0
+5. `ssh ub01 "docker ps --filter name=chrysopedia-watcher --format '{{.Status}}'"` — shows "Up N minutes (healthy)"
+6. End-to-end valid JSON ingestion verified on ub01 (HTTP 200, file in processed/)
+7. End-to-end invalid JSON handling verified on ub01 (file in failed/ with .error sidecar)
+
+## Requirements Advanced
+
+- R002 — Transcript ingestion now automated via folder watcher — no manual curl/upload needed
+
+## Requirements Validated
+
+None.
+
+## New Requirements Surfaced
+
+None.
+
+## Requirements Invalidated or Re-scoped
+
+None.
+
+## Deviations
+
+Healthcheck changed from `pgrep -f watcher.py` to `python -c "import os; os.kill(1, 0)"` because procps is not available in the slim Python Docker image.
+
+## Known Limitations
+
+None.
+
+## Follow-ups
+
+None.
+
+## Files Created/Modified
+
+- `backend/watcher.py` — New standalone folder watcher script using watchdog PollingObserver
+- `backend/requirements.txt` — Added watchdog>=4.0,<5.0 dependency
+- `docker-compose.yml` — Added chrysopedia-watcher service definition
diff --git a/.gsd/milestones/M007/slices/S03/S03-UAT.md b/.gsd/milestones/M007/slices/S03/S03-UAT.md
new file mode 100644
index 0000000..410c0db
--- /dev/null
+++ b/.gsd/milestones/M007/slices/S03/S03-UAT.md
@@ -0,0 +1,97 @@
+# S03: Transcript Folder Watcher — Auto-Ingest Service — UAT
+
+**Milestone:** M007
+**Written:** 2026-03-30T19:26:20.063Z
+
+## UAT: Transcript Folder Watcher — Auto-Ingest Service
+
+### Preconditions
+- Chrysopedia stack running on ub01 (`docker ps --filter name=chrysopedia` shows all services healthy)
+- Watch folder exists: `/vmPool/r/services/chrysopedia_watch/`
+- Watcher container running: `docker ps --filter name=chrysopedia-watcher` shows healthy
+
+---
+
+### Test 1: Valid Transcript Auto-Ingest (Happy Path)
+
+1. SSH to ub01
+2. Create a valid transcript JSON file:
+ ```bash
+ cat > /vmPool/r/services/chrysopedia_watch/uat_valid.json << 'EOF'
+ {
+ "source_file": "uat_test_video.mp4",
+ "creator_folder": "UAT Creator",
+ "duration_seconds": 120.5,
+ "segments": [{"start": 0.0, "end": 5.0, "text": "Hello world"}]
+ }
+ EOF
+ ```
+3. Wait 10 seconds for watcher to detect and process
+4. **Expected:** `docker logs chrysopedia-watcher --tail 20` shows:
+ - File pickup log line mentioning `uat_valid.json`
+ - Stability check pass
+ - POST to ingest API with HTTP 200
+ - File moved to processed/
+5. **Expected:** `ls /vmPool/r/services/chrysopedia_watch/processed/uat_valid.json` — file exists
+6. **Expected:** `ls /vmPool/r/services/chrysopedia_watch/uat_valid.json` — file no longer in root
+
+### Test 2: Invalid JSON File Handling
+
+1. Create an invalid JSON file (missing required keys):
+ ```bash
+ echo '{"foo": "bar"}' > /vmPool/r/services/chrysopedia_watch/uat_invalid.json
+ ```
+2. Wait 10 seconds
+3. **Expected:** `ls /vmPool/r/services/chrysopedia_watch/failed/uat_invalid.json` — file exists
+4. **Expected:** `cat /vmPool/r/services/chrysopedia_watch/failed/uat_invalid.json.error` — contains validation error mentioning missing keys
+5. **Expected:** File no longer in root watch folder
+
+### Test 3: Malformed JSON (Parse Error)
+
+1. Create a file with broken JSON:
+ ```bash
+ echo 'not json at all' > /vmPool/r/services/chrysopedia_watch/uat_broken.json
+ ```
+2. Wait 10 seconds
+3. **Expected:** File moved to `failed/uat_broken.json` with `.error` sidecar containing JSON parse error
+
+### Test 4: Non-JSON File Ignored
+
+1. Create a non-JSON file:
+ ```bash
+ echo 'hello' > /vmPool/r/services/chrysopedia_watch/readme.txt
+ ```
+2. Wait 10 seconds
+3. **Expected:** File remains in root watch folder — not moved to processed/ or failed/
+4. **Expected:** No log entry in watcher for this file
+
+### Test 5: File Stability (Simulated Partial Write)
+
+1. Start writing a large file slowly:
+ ```bash
+ (echo '{"source_file":"test.mp4",' ; sleep 3 ; echo '"creator_folder":"Test","duration_seconds":60,"segments":[]}') > /vmPool/r/services/chrysopedia_watch/uat_slow.json
+ ```
+2. **Expected:** Watcher waits for file stability before processing (logs show stability check)
+3. **Expected:** File ultimately processed (valid JSON) and moved to processed/
+
+### Test 6: Watcher Survives API Downtime
+
+1. Stop the API: `docker stop chrysopedia-api`
+2. Drop a valid JSON file into watch folder
+3. **Expected:** File moves to `failed/` with `.error` sidecar containing connection error
+4. Restart the API: `docker start chrysopedia-api`
+5. **Expected:** Watcher continues running (check `docker ps --filter name=chrysopedia-watcher`)
+
+### Cleanup
+```bash
+rm -f /vmPool/r/services/chrysopedia_watch/processed/uat_*.json
+rm -f /vmPool/r/services/chrysopedia_watch/failed/uat_*.json
+rm -f /vmPool/r/services/chrysopedia_watch/failed/uat_*.json.error
+rm -f /vmPool/r/services/chrysopedia_watch/readme.txt
+```
+
+### Operational Readiness (Q8)
+- **Health signal:** `docker ps --filter name=chrysopedia-watcher` shows healthy; `docker logs chrysopedia-watcher --tail 5` shows recent activity or idle polling
+- **Failure signal:** Container exits or becomes unhealthy; `.error` sidecar files accumulate in `failed/` directory
+- **Recovery procedure:** `docker compose restart chrysopedia-watcher` — no state to lose, watcher re-scans on startup
+- **Monitoring gaps:** No alerting on failed/ file accumulation; no metrics endpoint. Admin must manually check `failed/` directory or watcher logs.
diff --git a/.gsd/milestones/M007/slices/S03/tasks/T02-VERIFY.json b/.gsd/milestones/M007/slices/S03/tasks/T02-VERIFY.json
new file mode 100644
index 0000000..342e1b0
--- /dev/null
+++ b/.gsd/milestones/M007/slices/S03/tasks/T02-VERIFY.json
@@ -0,0 +1,9 @@
+{
+ "schemaVersion": 1,
+ "taskId": "T02",
+ "unitId": "M007/S03/T02",
+ "timestamp": 1774898679946,
+ "passed": true,
+ "discoverySource": "none",
+ "checks": []
+}
diff --git a/.gsd/milestones/M007/slices/S04/S04-PLAN.md b/.gsd/milestones/M007/slices/S04/S04-PLAN.md
index 7533e08..66a2da5 100644
--- a/.gsd/milestones/M007/slices/S04/S04-PLAN.md
+++ b/.gsd/milestones/M007/slices/S04/S04-PLAN.md
@@ -1,6 +1,29 @@
# S04: Admin UX Audit — Prune, Streamline, and Polish
-**Goal:** Audit all admin pages (Pipeline, Reports, Review). Remove unhelpful elements. Improve pipeline page layout for the primary workflow: check status, investigate issues, retrigger. Add clear visual hierarchy for the content management workflow.
+**Goal:** Admin pipeline page is cleaner and more efficient for daily content management. Debug mode toggle, status filter, workflow streamlining, and dead UI removal all deployed.
**Demo:** After this: Admin pipeline page is cleaner and more efficient for daily content management. Dead UI elements removed. Workflow improvements visible.
## Tasks
+- [x] **T01: Added DebugModeToggle component and StatusFilter pill bar to AdminPipeline page with API client functions for debug mode GET/PUT endpoints** — Add two new interactive features to AdminPipeline.tsx: (1) a debug mode toggle in the page header that reads/writes via GET/PUT /admin/pipeline/debug-mode, and (2) a status filter pill bar that filters the video list client-side by processing_status.
+
+Steps:
+1. Add `fetchDebugMode()` and `setDebugMode(enabled: boolean)` functions to `public-client.ts`. The backend endpoints are `GET /admin/pipeline/debug-mode` → `{enabled: boolean}` and `PUT /admin/pipeline/debug-mode` with body `{enabled: boolean}`.
+2. In AdminPipeline.tsx, add state for `debugMode` (boolean) and `debugLoading` (boolean). Fetch debug mode on mount. Add a toggle switch in the header-right area next to WorkerStatus — use the same visual pattern as the ModeToggle component on ReviewQueue (a labeled toggle with on/off state). Clicking the toggle calls `setDebugMode(!debugMode)` then updates local state.
+3. Add a status filter pill bar below the page header. Extract unique statuses from the `videos` array. Add `activeFilter` state (string | null, default null = show all). Render filter pills for each status plus an 'All' pill. Filter the `videos` array before rendering when `activeFilter` is set. Use the existing `filter-tab` / `filter-tab--active` CSS classes from ReviewQueue.
+4. Add CSS for the debug mode toggle (`.debug-toggle`, `.debug-toggle__switch`, `.debug-toggle__label`) in App.css. Style it to match the dark theme using existing `var(--color-*)` custom properties.
+5. Verify: Docker build succeeds, toggle visible and functional, filter pills render and filter correctly.
+ - Estimate: 45m
+ - Files: frontend/src/api/public-client.ts, frontend/src/pages/AdminPipeline.tsx, frontend/src/App.css
+ - Verify: ssh ub01 "cd /vmPool/r/repos/xpltdco/chrysopedia && docker compose build chrysopedia-web" succeeds with exit 0
+- [ ] **T02: Prune dead UI, rename view toggle, add debug indicator on trigger, add review queue cross-link** — Clean up the pipeline page: remove low-value UI elements, rename confusing labels, add debug mode context to trigger button, and add cross-navigation to review queue.
+
+Steps:
+1. In EventLog component, rename 'Head'/'Tail' buttons to 'Oldest first'/'Newest first'. The buttons are in the `.pipeline-events__view-toggle` div. Just change the button text.
+2. In EventLog component, remove the duplicate '↻ Refresh' button from `.pipeline-events__header`. The page-level refresh in the main header is sufficient.
+3. In the expanded video detail section (the `pipeline-video__detail` div), remove the `ID: {video.id.slice(0, 8)}…` span. Keep Created and Updated dates.
+4. In the video header actions area, add a '(debug)' indicator next to the Trigger button when debug mode is active. Pass `debugMode` state from the parent AdminPipeline component down to or alongside the trigger button. When debugMode is true, the trigger button text changes to '▶ Trigger (debug)' to indicate the run will capture full LLM I/O.
+5. Add a cross-link button/icon on each pipeline video card that navigates to the review queue filtered by that video. Use `` or React Router Link with the video's creator name as context. A small '→ Moments' link in the video meta area works. Since the review queue doesn't currently support URL-based video filtering, just link to `/admin/review` with the video filename as a visual cue.
+6. Verify: Docker build succeeds, view toggle says 'Oldest first'/'Newest first', no event-level refresh button, no event ID in detail, debug indicator on trigger when debug mode is on, cross-link visible.
+ - Estimate: 30m
+ - Files: frontend/src/pages/AdminPipeline.tsx, frontend/src/App.css
+ - Verify: ssh ub01 "cd /vmPool/r/repos/xpltdco/chrysopedia && docker compose build chrysopedia-web" succeeds with exit 0
diff --git a/.gsd/milestones/M007/slices/S04/S04-RESEARCH.md b/.gsd/milestones/M007/slices/S04/S04-RESEARCH.md
new file mode 100644
index 0000000..41d4bc6
--- /dev/null
+++ b/.gsd/milestones/M007/slices/S04/S04-RESEARCH.md
@@ -0,0 +1,101 @@
+# S04 Research: Admin UX Audit — Prune, Streamline, and Polish
+
+## Summary
+
+This is a **light research** slice — straightforward UX cleanup using established patterns in an already well-structured codebase. The admin pages (Pipeline, Reports, Review) are functional but have accumulated some gaps and could benefit from streamlining for the single-admin daily workflow.
+
+## Recommendation
+
+Three tasks: (1) Add debug mode toggle + video status filter to Pipeline page, (2) Cross-link admin pages and add workflow shortcuts, (3) CSS/UX polish pass. All tasks are independent and could run in parallel.
+
+## Implementation Landscape
+
+### Admin Pages Inventory (canonical on ub01)
+
+| Page | File | Lines | Role |
+|------|------|-------|------|
+| Pipeline Management | `frontend/src/pages/AdminPipeline.tsx` | 535 | Video list, status, trigger/revoke, event log, worker status, debug payload viewer |
+| Content Reports | `frontend/src/pages/AdminReports.tsx` | 246 | User-submitted issue reports, triage workflow |
+| Review Queue | `frontend/src/pages/ReviewQueue.tsx` | 189 | Key moment review with stats, filters, pagination |
+| Moment Detail | `frontend/src/pages/MomentDetail.tsx` | 454 | Single moment review with approve/reject/edit/split/merge |
+
+### Supporting Components
+
+| Component | File | Lines | Used By |
+|-----------|------|-------|---------|
+| AdminDropdown | `components/AdminDropdown.tsx` | 73 | App.tsx header nav |
+| ModeToggle | `components/ModeToggle.tsx` | 59 | ReviewQueue.tsx |
+| AppFooter | `components/AppFooter.tsx` | 47 | App.tsx |
+| StatusBadge | `components/StatusBadge.tsx` | 19 | ReviewQueue, MomentDetail |
+| ReportIssueModal | `components/ReportIssueModal.tsx` | 135 | TechniquePage.tsx only |
+
+### API Clients
+
+- `api/client.ts` — Review queue endpoints only (BASE = `/api/v1/review`)
+- `api/public-client.ts` — Everything else: search, techniques, creators, topics, pipeline admin, reports
+
+Both have independent `request()` helpers with identical error-handling logic. This duplication is mild but noted.
+
+### CSS
+
+Single `App.css` at 3082 lines. Uses 77 CSS custom properties (D017). Admin-specific styles start around line 2265 (reports) and 2577 (pipeline). BEM naming throughout.
+
+## Concrete Findings
+
+### 1. Missing Debug Mode Toggle on Pipeline Page
+
+Backend has `GET/PUT /admin/pipeline/debug-mode` endpoints (pipeline.py lines 244-278, using Redis key `chrysopedia:debug_mode`). **No frontend UI exposes this toggle.** The `ModeToggle` component on the Review Queue page is a good pattern to follow, but the debug mode toggle serves a different purpose (controls whether pipeline captures full LLM I/O per S01).
+
+**Action:** Add a debug mode toggle to the Pipeline page header, next to WorkerStatus. Reuse the ModeToggle visual pattern. Add `fetchDebugMode` / `setDebugMode` functions to `public-client.ts`.
+
+### 2. No Video Status Filter on Pipeline Page
+
+The pipeline page shows all videos in a flat list. With growing content, the admin needs to filter by `processing_status` (pending, processing, extracted, completed, failed, etc.). The `statusBadgeClass()` function already maps all statuses to visual styles.
+
+**Action:** Add a status filter pill bar (same pattern as ReviewQueue's FILTERS and AdminReports's STATUS_OPTIONS). Filter client-side — the video list is small enough (currently fetched all at once via `fetchPipelineVideos()`).
+
+### 3. No Cross-Links Between Admin Pages
+
+- Pipeline video → no link to its technique pages or review queue moments
+- Review queue → no link back to pipeline for the source video
+- No way to jump from a pipeline event to the related technique page
+
+**Action:** Add subtle cross-link buttons/icons where natural. E.g., a "View moments" link on each pipeline video card that navigates to `/admin/review?video_id=...` (would need review queue to support URL-based filtering, or just use the video filename as visual context). Keep this simple — a link to review queue filtered to that video's moments is the most valuable.
+
+### 4. Dead/Low-Value UI Elements
+
+- **"Head/Tail" view toggle** on event log — the distinction is confusing for an admin ("head" means oldest-first, "tail" means newest-first). Consider renaming to "Oldest first" / "Newest first" or just defaulting to newest-first and removing the toggle.
+- **Event ID display** in expanded video detail (`ID: {video.id.slice(0, 8)}…`) — not useful for the admin workflow. Created/Updated dates are marginally useful.
+- **"↻ Refresh" button** appears twice on pipeline page — once in the header, once per event log. The header one is sufficient; auto-refresh on a timer (like WorkerStatus does every 15s) would be better.
+
+### 5. Pipeline Page Doesn't Show Debug Mode State Visually
+
+When debug mode is on, the admin should see a visual indicator so they know pipeline runs will be capturing full prompts. Without this, they might wonder why events have debug payloads or not.
+
+### 6. Trigger Button Lacks Debug Mode Context
+
+When triggering a pipeline run, there's no indication whether it will run in debug mode or not. A small "(debug)" label next to the trigger button when debug mode is active would clarify.
+
+## Files That Will Change
+
+| File | What Changes |
+|------|-------------|
+| `frontend/src/pages/AdminPipeline.tsx` | Add debug mode toggle, status filter, rename head/tail, prune low-value elements, add cross-links |
+| `frontend/src/api/public-client.ts` | Add `fetchDebugMode()` / `setDebugMode()` API functions |
+| `frontend/src/App.css` | Add debug-mode toggle styles, status filter styles, polish tweaks |
+
+## Constraints
+
+- **All changes on ub01** — canonical dev directory is `/vmPool/r/repos/xpltdco/chrysopedia`
+- **Deploy via Docker Compose rebuild** — `docker compose build chrysopedia-web && docker compose up -d chrysopedia-web-8096`
+- **Container name vs service name** — build target is `chrysopedia-web`, container is `chrysopedia-web-8096`
+- **No backend changes needed** — all required API endpoints already exist
+- **Frontend-only slice** — TypeScript + CSS changes only
+
+## Verification Strategy
+
+1. `docker compose build chrysopedia-web` succeeds (TypeScript compiles)
+2. Container healthy: `curl -s http://localhost:8096/ | grep -q Chrysopedia`
+3. Browser verification of pipeline page: debug toggle visible, status filter functional, head/tail renamed, cross-links present
+4. Debug mode toggle: click toggle → API call succeeds → visual state updates
+5. Status filter: click filter pill → video list filters correctly
diff --git a/.gsd/milestones/M007/slices/S04/tasks/T01-PLAN.md b/.gsd/milestones/M007/slices/S04/tasks/T01-PLAN.md
new file mode 100644
index 0000000..bf4d9cc
--- /dev/null
+++ b/.gsd/milestones/M007/slices/S04/tasks/T01-PLAN.md
@@ -0,0 +1,33 @@
+---
+estimated_steps: 7
+estimated_files: 3
+skills_used: []
+---
+
+# T01: Add debug mode toggle and video status filter to Pipeline page
+
+Add two new interactive features to AdminPipeline.tsx: (1) a debug mode toggle in the page header that reads/writes via GET/PUT /admin/pipeline/debug-mode, and (2) a status filter pill bar that filters the video list client-side by processing_status.
+
+Steps:
+1. Add `fetchDebugMode()` and `setDebugMode(enabled: boolean)` functions to `public-client.ts`. The backend endpoints are `GET /admin/pipeline/debug-mode` → `{enabled: boolean}` and `PUT /admin/pipeline/debug-mode` with body `{enabled: boolean}`.
+2. In AdminPipeline.tsx, add state for `debugMode` (boolean) and `debugLoading` (boolean). Fetch debug mode on mount. Add a toggle switch in the header-right area next to WorkerStatus — use the same visual pattern as the ModeToggle component on ReviewQueue (a labeled toggle with on/off state). Clicking the toggle calls `setDebugMode(!debugMode)` then updates local state.
+3. Add a status filter pill bar below the page header. Extract unique statuses from the `videos` array. Add `activeFilter` state (string | null, default null = show all). Render filter pills for each status plus an 'All' pill. Filter the `videos` array before rendering when `activeFilter` is set. Use the existing `filter-tab` / `filter-tab--active` CSS classes from ReviewQueue.
+4. Add CSS for the debug mode toggle (`.debug-toggle`, `.debug-toggle__switch`, `.debug-toggle__label`) in App.css. Style it to match the dark theme using existing `var(--color-*)` custom properties.
+5. Verify: Docker build succeeds, toggle visible and functional, filter pills render and filter correctly.
+
+## Inputs
+
+- ``frontend/src/api/public-client.ts` — existing API client, needs debug mode functions added`
+- ``frontend/src/pages/AdminPipeline.tsx` — main pipeline page component (535 lines)`
+- ``frontend/src/App.css` — global stylesheet with CSS custom properties`
+- ``frontend/src/pages/ReviewQueue.tsx` — reference for filter pill pattern and ModeToggle visual pattern`
+
+## Expected Output
+
+- ``frontend/src/api/public-client.ts` — added fetchDebugMode() and setDebugMode() functions`
+- ``frontend/src/pages/AdminPipeline.tsx` — added DebugModeToggle component and status filter pill bar with state management`
+- ``frontend/src/App.css` — added debug-toggle and status-filter CSS styles`
+
+## Verification
+
+ssh ub01 "cd /vmPool/r/repos/xpltdco/chrysopedia && docker compose build chrysopedia-web" succeeds with exit 0
diff --git a/.gsd/milestones/M007/slices/S04/tasks/T01-SUMMARY.md b/.gsd/milestones/M007/slices/S04/tasks/T01-SUMMARY.md
new file mode 100644
index 0000000..b566161
--- /dev/null
+++ b/.gsd/milestones/M007/slices/S04/tasks/T01-SUMMARY.md
@@ -0,0 +1,79 @@
+---
+id: T01
+parent: S04
+milestone: M007
+provides: []
+requires: []
+affects: []
+key_files: ["frontend/src/pages/AdminPipeline.tsx", "frontend/src/api/public-client.ts", "frontend/src/App.css"]
+key_decisions: ["Reused mode-toggle CSS pattern for debug toggle", "StatusFilter shows counts in pill labels and hides when only 1 status exists"]
+patterns_established: []
+drill_down_paths: []
+observability_surfaces: []
+duration: ""
+verification_result: "Docker build of chrysopedia-web service succeeded with exit 0. TypeScript compiled cleanly, Vite bundled 48 modules, nginx image produced."
+completed_at: 2026-03-30T19:34:04.184Z
+blocker_discovered: false
+---
+
+# T01: Added DebugModeToggle component and StatusFilter pill bar to AdminPipeline page with API client functions for debug mode GET/PUT endpoints
+
+> Added DebugModeToggle component and StatusFilter pill bar to AdminPipeline page with API client functions for debug mode GET/PUT endpoints
+
+## What Happened
+---
+id: T01
+parent: S04
+milestone: M007
+key_files:
+ - frontend/src/pages/AdminPipeline.tsx
+ - frontend/src/api/public-client.ts
+ - frontend/src/App.css
+key_decisions:
+ - Reused mode-toggle CSS pattern for debug toggle
+ - StatusFilter shows counts in pill labels and hides when only 1 status exists
+duration: ""
+verification_result: passed
+completed_at: 2026-03-30T19:34:04.184Z
+blocker_discovered: false
+---
+
+# T01: Added DebugModeToggle component and StatusFilter pill bar to AdminPipeline page with API client functions for debug mode GET/PUT endpoints
+
+**Added DebugModeToggle component and StatusFilter pill bar to AdminPipeline page with API client functions for debug mode GET/PUT endpoints**
+
+## What Happened
+
+Added fetchDebugMode() and setDebugMode() API client functions to public-client.ts for the existing backend debug-mode endpoints. Created DebugModeToggle component (fetch on mount, toggle switch with active state) placed in the header-right area next to WorkerStatus. Created StatusFilter component using the existing filter-tab CSS pattern, with per-status counts and an All pill. Videos are filtered client-side via activeFilter state. Added debug-toggle CSS matching the dark theme toggle pattern.
+
+## Verification
+
+Docker build of chrysopedia-web service succeeded with exit 0. TypeScript compiled cleanly, Vite bundled 48 modules, nginx image produced.
+
+## Verification Evidence
+
+| # | Command | Exit Code | Verdict | Duration |
+|---|---------|-----------|---------|----------|
+| 1 | `ssh ub01 "cd /vmPool/r/repos/xpltdco/chrysopedia && docker compose build chrysopedia-web"` | 0 | ✅ pass | 8100ms |
+
+
+## Deviations
+
+Docker service name is chrysopedia-web not chrysopedia-web-8096. Backend response uses debug_mode (snake_case) not enabled as task plan implied.
+
+## Known Issues
+
+None.
+
+## Files Created/Modified
+
+- `frontend/src/pages/AdminPipeline.tsx`
+- `frontend/src/api/public-client.ts`
+- `frontend/src/App.css`
+
+
+## Deviations
+Docker service name is chrysopedia-web not chrysopedia-web-8096. Backend response uses debug_mode (snake_case) not enabled as task plan implied.
+
+## Known Issues
+None.
diff --git a/.gsd/milestones/M007/slices/S04/tasks/T02-PLAN.md b/.gsd/milestones/M007/slices/S04/tasks/T02-PLAN.md
new file mode 100644
index 0000000..acd5538
--- /dev/null
+++ b/.gsd/milestones/M007/slices/S04/tasks/T02-PLAN.md
@@ -0,0 +1,31 @@
+---
+estimated_steps: 8
+estimated_files: 2
+skills_used: []
+---
+
+# T02: Prune dead UI, rename view toggle, add debug indicator on trigger, add review queue cross-link
+
+Clean up the pipeline page: remove low-value UI elements, rename confusing labels, add debug mode context to trigger button, and add cross-navigation to review queue.
+
+Steps:
+1. In EventLog component, rename 'Head'/'Tail' buttons to 'Oldest first'/'Newest first'. The buttons are in the `.pipeline-events__view-toggle` div. Just change the button text.
+2. In EventLog component, remove the duplicate '↻ Refresh' button from `.pipeline-events__header`. The page-level refresh in the main header is sufficient.
+3. In the expanded video detail section (the `pipeline-video__detail` div), remove the `ID: {video.id.slice(0, 8)}…` span. Keep Created and Updated dates.
+4. In the video header actions area, add a '(debug)' indicator next to the Trigger button when debug mode is active. Pass `debugMode` state from the parent AdminPipeline component down to or alongside the trigger button. When debugMode is true, the trigger button text changes to '▶ Trigger (debug)' to indicate the run will capture full LLM I/O.
+5. Add a cross-link button/icon on each pipeline video card that navigates to the review queue filtered by that video. Use `` or React Router Link with the video's creator name as context. A small '→ Moments' link in the video meta area works. Since the review queue doesn't currently support URL-based video filtering, just link to `/admin/review` with the video filename as a visual cue.
+6. Verify: Docker build succeeds, view toggle says 'Oldest first'/'Newest first', no event-level refresh button, no event ID in detail, debug indicator on trigger when debug mode is on, cross-link visible.
+
+## Inputs
+
+- ``frontend/src/pages/AdminPipeline.tsx` — pipeline page with debug toggle and status filter from T01`
+- ``frontend/src/App.css` — global stylesheet with debug-toggle styles from T01`
+
+## Expected Output
+
+- ``frontend/src/pages/AdminPipeline.tsx` — head/tail renamed, duplicate refresh removed, event ID removed, debug indicator on trigger, review queue cross-link added`
+- ``frontend/src/App.css` — any minor style additions for cross-link`
+
+## Verification
+
+ssh ub01 "cd /vmPool/r/repos/xpltdco/chrysopedia && docker compose build chrysopedia-web" succeeds with exit 0
diff --git a/frontend/src/App.css b/frontend/src/App.css
index ba54afb..c641cf9 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -541,6 +541,57 @@ a.app-footer__repo:hover {
cursor: not-allowed;
}
+/* ── Debug Mode Toggle ────────────────────────────────────────────────────── */
+
+.debug-toggle {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.8125rem;
+}
+
+.debug-toggle__label {
+ color: var(--color-text-on-header-label);
+ white-space: nowrap;
+}
+
+.debug-toggle__switch {
+ position: relative;
+ width: 2.5rem;
+ height: 1.25rem;
+ background: var(--color-toggle-track);
+ border: none;
+ border-radius: 9999px;
+ cursor: pointer;
+ transition: background 0.2s;
+ flex-shrink: 0;
+}
+
+.debug-toggle__switch--active {
+ background: var(--color-toggle-review);
+}
+
+.debug-toggle__switch::after {
+ content: "";
+ position: absolute;
+ top: 0.125rem;
+ left: 0.125rem;
+ width: 1rem;
+ height: 1rem;
+ background: var(--color-toggle-thumb);
+ border-radius: 50%;
+ transition: transform 0.2s;
+}
+
+.debug-toggle__switch--active::after {
+ transform: translateX(1.25rem);
+}
+
+.debug-toggle__switch:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
/* ── Pagination ───────────────────────────────────────────────────────────── */
.pagination {
diff --git a/frontend/src/api/public-client.ts b/frontend/src/api/public-client.ts
index 4f6817b..8e9133f 100644
--- a/frontend/src/api/public-client.ts
+++ b/frontend/src/api/public-client.ts
@@ -471,3 +471,20 @@ export async function revokePipeline(videoId: string): Promise {
method: "POST",
});
}
+
+// ── Debug Mode ──────────────────────────────────────────────────────────────
+
+export interface DebugModeResponse {
+ debug_mode: boolean;
+}
+
+export async function fetchDebugMode(): Promise {
+ return request(`${BASE}/admin/pipeline/debug-mode`);
+}
+
+export async function setDebugMode(enabled: boolean): Promise {
+ return request(`${BASE}/admin/pipeline/debug-mode`, {
+ method: "PUT",
+ body: JSON.stringify({ debug_mode: enabled }),
+ });
+}
diff --git a/frontend/src/pages/AdminPipeline.tsx b/frontend/src/pages/AdminPipeline.tsx
index 2a1ac72..f8f9c73 100644
--- a/frontend/src/pages/AdminPipeline.tsx
+++ b/frontend/src/pages/AdminPipeline.tsx
@@ -8,6 +8,8 @@ import {
fetchPipelineVideos,
fetchPipelineEvents,
fetchWorkerStatus,
+ fetchDebugMode,
+ setDebugMode,
triggerPipeline,
revokePipeline,
type PipelineVideoItem,
@@ -364,6 +366,93 @@ function WorkerStatus() {
);
}
+// ── Debug Mode Toggle ────────────────────────────────────────────────────────
+
+function DebugModeToggle() {
+ const [debugMode, setDebugModeState] = useState(null);
+ const [debugLoading, setDebugLoading] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ fetchDebugMode()
+ .then((res) => {
+ if (!cancelled) setDebugModeState(res.debug_mode);
+ })
+ .catch(() => {
+ // silently fail — toggle stays hidden
+ });
+ return () => { cancelled = true; };
+ }, []);
+
+ async function handleToggle() {
+ if (debugMode === null || debugLoading) return;
+ setDebugLoading(true);
+ try {
+ const res = await setDebugMode(!debugMode);
+ setDebugModeState(res.debug_mode);
+ } catch {
+ // swallow — leave previous state
+ } finally {
+ setDebugLoading(false);
+ }
+ }
+
+ if (debugMode === null) return null;
+
+ return (
+
+
+ Debug {debugMode ? "On" : "Off"}
+
+
+
+ );
+}
+
+// ── Status Filter ────────────────────────────────────────────────────────────
+
+function StatusFilter({
+ videos,
+ activeFilter,
+ onFilterChange,
+}: {
+ videos: PipelineVideoItem[];
+ activeFilter: string | null;
+ onFilterChange: (filter: string | null) => void;
+}) {
+ const statuses = Array.from(new Set(videos.map((v) => v.processing_status))).sort();
+
+ if (statuses.length <= 1) return null;
+
+ return (
+
+
+ {statuses.map((status) => (
+
+ ))}
+
+ );
+}
+
// ── Main Page ────────────────────────────────────────────────────────────────
export default function AdminPipeline() {
@@ -373,6 +462,7 @@ export default function AdminPipeline() {
const [expandedId, setExpandedId] = useState(null);
const [actionLoading, setActionLoading] = useState(null);
const [actionMessage, setActionMessage] = useState<{ id: string; text: string; ok: boolean } | null>(null);
+ const [activeFilter, setActiveFilter] = useState(null);
const load = useCallback(async () => {
setLoading(true);
@@ -448,6 +538,7 @@ export default function AdminPipeline() {
+