diff --git a/.gsd/KNOWLEDGE.md b/.gsd/KNOWLEDGE.md index 30c5f63..c090549 100644 --- a/.gsd/KNOWLEDGE.md +++ b/.gsd/KNOWLEDGE.md @@ -362,3 +362,15 @@ **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. diff --git a/.gsd/milestones/M023/M023-ROADMAP.md b/.gsd/milestones/M023/M023-ROADMAP.md index cc9d94f..bcc03f7 100644 --- a/.gsd/milestones/M023/M023-ROADMAP.md +++ b/.gsd/milestones/M023/M023-ROADMAP.md @@ -6,7 +6,7 @@ The demo MVP comes together. Chat widget wires to the intelligence layer (INT-1) ## Slice Overview | ID | Slice | Risk | Depends | Done | After this | |----|-------|------|---------|------|------------| -| S01 | [A] Post Editor + File Sharing | high | — | ⬜ | Creator writes rich text posts with file attachments (presets, sample packs). Followers see posts in feed. Files downloadable via signed URLs. | +| S01 | [A] Post Editor + File Sharing | high | — | ✅ | Creator writes rich text posts with file attachments (presets, sample packs). Followers see posts in feed. Files downloadable via signed URLs. | | S02 | [A] Chat Widget ↔ Chat Engine Wiring (INT-1) | high | — | ⬜ | Chat widget on creator profile wired to chat engine. Personality slider adjusts response style. Citations link to sources. | | S03 | [B] Shorts Generation Pipeline v1 | medium | — | ⬜ | Shorts pipeline extracts clips from highlight boundaries in 3 format presets (vertical, square, horizontal) | | S04 | [B] Personality Slider (Full Interpolation) | medium | — | ⬜ | Personality slider at 0.0 gives encyclopedic response. At 1.0 gives creator-voiced response with their speech patterns. | diff --git a/.gsd/milestones/M023/slices/S01/S01-SUMMARY.md b/.gsd/milestones/M023/slices/S01/S01-SUMMARY.md new file mode 100644 index 0000000..bc6c741 --- /dev/null +++ b/.gsd/milestones/M023/slices/S01/S01-SUMMARY.md @@ -0,0 +1,151 @@ +--- +id: S01 +parent: M023 +milestone: M023 +provides: + - Post CRUD API at /api/v1/posts + - File upload/download API at /api/v1/files + - MinIO object storage integration + - PostsFeed component for creator profile pages + - PostEditor page for creator content authoring + - PostsList management page for creators +requires: + [] +affects: + - S05 +key_files: + - docker-compose.yml + - backend/minio_client.py + - backend/routers/posts.py + - backend/routers/files.py + - backend/models.py + - backend/schemas.py + - backend/auth.py + - backend/main.py + - alembic/versions/024_add_posts_and_attachments.py + - frontend/src/pages/PostEditor.tsx + - frontend/src/pages/PostEditor.module.css + - frontend/src/api/posts.ts + - frontend/src/api/client.ts + - frontend/src/components/PostsFeed.tsx + - frontend/src/components/PostsFeed.module.css + - frontend/src/pages/PostsList.tsx + - frontend/src/pages/PostsList.module.css + - frontend/src/pages/CreatorDetail.tsx + - frontend/src/App.tsx + - frontend/src/pages/CreatorDashboard.tsx + - docker/nginx.conf + - backend/config.py + - backend/requirements.txt +key_decisions: + - MinIO internal-only (no public port) — files served via API-generated presigned URLs + - get_optional_user dependency for public endpoints with auth-aware behavior + - MinIO object deletion on post delete is best-effort (logged, non-blocking) + - Tiptap v3 with StarterKit + Link + Placeholder for rich text editing + - PostsFeed hides section entirely when creator has zero posts + - File uploads use run_in_executor for sync MinIO I/O in async handlers +patterns_established: + - MinIO integration pattern: lazy-init singleton, ensure_bucket on first write, presigned URLs for downloads + - requestMultipart API client helper for FormData uploads without Content-Type header + - get_optional_user auth dependency for public-but-auth-aware endpoints + - Tiptap JSON→HTML rendering with generateHTML + extensions for read-only display +observability_surfaces: + - File upload errors logged with MinIO error message and object_key + - Post ownership violations logged at WARNING + - MinIO bucket auto-creation logged on startup + - 503 status for MinIO failures, 403 for ownership violations, 404 for missing resources +drill_down_paths: + - .gsd/milestones/M023/slices/S01/tasks/T01-SUMMARY.md + - .gsd/milestones/M023/slices/S01/tasks/T02-SUMMARY.md + - .gsd/milestones/M023/slices/S01/tasks/T03-SUMMARY.md + - .gsd/milestones/M023/slices/S01/tasks/T04-SUMMARY.md +duration: "" +verification_result: passed +completed_at: 2026-04-04T09:19:26.082Z +blocker_discovered: false +--- + +# S01: [A] Post Editor + File Sharing + +**Full post editor with Tiptap rich text, MinIO-backed file attachments, CRUD API, public feed rendering, and creator management page.** + +## What Happened + +This slice delivered the complete post system — write path, read path, and file storage. + +**T01 (Data Layer):** Added MinIO Docker service (internal-only, no public port) to docker-compose.yml with healthcheck and volume mount. Created minio_client.py with lazy-init singleton, ensure_bucket, upload_file, delete_file, and presigned URL generation. Added Post and PostAttachment SQLAlchemy models with UUID PKs and cascade delete. Created Alembic migration 024. Added Pydantic schemas for all CRUD operations. Bumped nginx client_max_body_size to 100m. + +**T02 (API Layer):** Built posts.py router with 5 CRUD endpoints enforcing creator ownership via auth. Introduced get_optional_user dependency (OAuth2PasswordBearer with auto_error=False) for public list endpoints that show drafts only to owners. Built files.py router with multipart upload (run_in_executor for sync MinIO I/O) and signed download URL generation. Registered both routers and added MinIO bucket auto-creation in the app lifespan handler. + +**T03 (Post Editor):** Installed Tiptap v3 with StarterKit, Link, and Placeholder extensions. Built PostEditor page with formatting toolbar, title input, drag-and-drop file attachment zone, publish toggle, and sequential save flow (create post → upload files). Added requestMultipart helper to the API client for FormData uploads. Lazy-loaded routes at /creator/posts/new and /creator/posts/:postId/edit. + +**T04 (Public Feed + Management):** Created PostsFeed component rendering published posts with Tiptap JSON→HTML conversion and signed-URL download buttons. Integrated into CreatorDetail page (hidden when no posts). Built PostsList management page with status badges, edit/delete actions, and confirmation dialog. Added SidebarNav link and route at /creator/posts. + +## Verification + +All slice verification checks passed: +1. `docker compose config --quiet` — exit 0 +2. All backend imports (Post, PostAttachment, PostCreate, PostRead, PostAttachmentRead, PostListResponse, get_minio_client, generate_download_url, upload_file, ensure_bucket, get_optional_user, posts router, files router) — exit 0 +3. `cd frontend && npm run build` — exit 0, 179 modules, PostEditor and PostsList lazy-loaded as separate chunks +4. Route registration verified: 8 new API routes (5 posts CRUD + 2 files + bucket init), 3 frontend routes (/creator/posts, /creator/posts/new, /creator/posts/:postId/edit) + +## Requirements Advanced + +None. + +## Requirements Validated + +None. + +## New Requirements Surfaced + +None. + +## Requirements Invalidated or Re-scoped + +None. + +## Deviations + +- Added get_optional_user auth dependency (not in plan, needed for public list with draft visibility) +- Added delete_file() to minio_client.py (needed for post deletion cleanup) +- Tiptap v3 required immediatelyRender: false for React 18 compatibility +- SidebarNav links to Posts management page instead of direct New Post link (better UX with management page available) + +## Known Limitations + +- Alembic migration 024 not yet applied to live database (deploy-time) +- PostsFeed fetches all posts for a creator in one call (no pagination on public profile) +- File upload reads entire file into memory before MinIO write + +## Follow-ups + +- Apply migration 024 on ub01 deploy +- Add pagination to PostsFeed if post counts grow +- Consider streaming file uploads for very large files + +## Files Created/Modified + +- `docker-compose.yml` — Added MinIO service with healthcheck, volume, internal network +- `backend/config.py` — Added MinIO connection settings +- `backend/minio_client.py` — Created — lazy-init MinIO client, ensure_bucket, upload, delete, presigned URL +- `backend/models.py` — Added Post and PostAttachment models with FKs and cascade +- `backend/schemas.py` — Added post CRUD and attachment Pydantic schemas +- `backend/requirements.txt` — Added minio package +- `backend/auth.py` — Added get_optional_user dependency +- `backend/routers/posts.py` — Created — 5 CRUD endpoints with auth and ownership +- `backend/routers/files.py` — Created — multipart upload and signed download URL +- `backend/main.py` — Registered posts and files routers, added MinIO bucket init +- `alembic/versions/024_add_posts_and_attachments.py` — Created — posts and post_attachments tables +- `docker/nginx.conf` — Bumped client_max_body_size to 100m +- `frontend/src/api/client.ts` — Added requestMultipart helper +- `frontend/src/api/posts.ts` — Created — TypeScript types and API functions for posts +- `frontend/src/pages/PostEditor.tsx` — Created — Tiptap editor with toolbar, file attachments, save flow +- `frontend/src/pages/PostEditor.module.css` — Created — dark theme editor styles +- `frontend/src/components/PostsFeed.tsx` — Created — public feed with HTML rendering and downloads +- `frontend/src/components/PostsFeed.module.css` — Created — feed card styles +- `frontend/src/pages/PostsList.tsx` — Created — creator post management page +- `frontend/src/pages/PostsList.module.css` — Created — management page styles +- `frontend/src/pages/CreatorDetail.tsx` — Integrated PostsFeed component +- `frontend/src/App.tsx` — Added PostEditor, PostsList lazy imports and routes +- `frontend/src/pages/CreatorDashboard.tsx` — Added Posts link to SidebarNav diff --git a/.gsd/milestones/M023/slices/S01/S01-UAT.md b/.gsd/milestones/M023/slices/S01/S01-UAT.md new file mode 100644 index 0000000..50f54bd --- /dev/null +++ b/.gsd/milestones/M023/slices/S01/S01-UAT.md @@ -0,0 +1,84 @@ +# S01: [A] Post Editor + File Sharing — UAT + +**Milestone:** M023 +**Written:** 2026-04-04T09:19:26.083Z + +## UAT: S01 — Post Editor + File Sharing + +### Preconditions +- Chrysopedia stack running on ub01 (docker compose up -d) +- Migration 024 applied (alembic upgrade head) +- MinIO container healthy (docker ps shows chrysopedia-minio healthy) +- At least one creator account with login credentials +- A test file (any .zip or .wav, 1-50MB) + +--- + +### TC-01: Post Creation (Happy Path) +1. Log in as creator at /login +2. Navigate to /creator/posts via sidebar → "Posts" link +3. **Expected:** PostsList page renders, shows "New Post" button +4. Click "New Post" +5. **Expected:** PostEditor renders with title input, Tiptap editor with toolbar, file attachment area, publish toggle +6. Enter title: "Test Post — Sample Pack Release" +7. Type rich text in editor: a paragraph, then bold text, then a bullet list +8. **Expected:** Toolbar buttons (B, I, H2, H3, list, link, code) toggle formatting visually +9. Toggle "Published" on +10. Attach a file by clicking the file attachment area and selecting the test file +11. **Expected:** File appears in attachment list below editor with filename and remove button +12. Click "Save" +13. **Expected:** Post created, files uploaded, redirects to creator dashboard or posts list + +### TC-02: Post Listing (Creator Management) +1. Navigate to /creator/posts +2. **Expected:** PostsList shows the post from TC-01 with title, "Published" badge, created date, Edit and Delete buttons +3. **Expected:** "New Post" button visible at top + +### TC-03: Post Editing +1. From PostsList, click "Edit" on the TC-01 post +2. **Expected:** PostEditor loads with existing title, body content, and attached file +3. Change the title to "Updated — Sample Pack v2" +4. Click "Save" +5. **Expected:** Post updates successfully + +### TC-04: Public Feed Display +1. Log out (or open incognito) +2. Navigate to the creator's public profile page (/creators/{slug}) +3. **Expected:** "Posts" section appears below technique grid +4. **Expected:** Post card shows title, rendered rich text (bold, lists visible), timestamp +5. **Expected:** File attachment shows with download button (filename, size) + +### TC-05: File Download via Signed URL +1. On the public creator profile, click the download button on the post attachment +2. **Expected:** Browser initiates file download (MinIO presigned URL) +3. **Expected:** Downloaded file matches the original upload + +### TC-06: Draft Visibility +1. Log in as creator, create a new post, leave "Published" toggled OFF, save +2. Navigate to /creator/posts +3. **Expected:** New draft post shows with "Draft" badge +4. Log out, visit creator's public profile +5. **Expected:** Draft post is NOT visible in the PostsFeed + +### TC-07: Post Deletion +1. Log in as creator, navigate to /creator/posts +2. Click "Delete" on a post +3. **Expected:** Confirmation dialog appears +4. Confirm deletion +5. **Expected:** Post removed from list, no longer visible on public profile + +### TC-08: Ownership Enforcement +1. Log in as a different user (not the post creator) +2. Attempt PUT /api/v1/posts/{post_id} via curl with a valid auth token for the wrong user +3. **Expected:** 403 Forbidden response +4. Attempt DELETE /api/v1/posts/{post_id} with wrong user token +5. **Expected:** 403 Forbidden response +6. Attempt POST /api/v1/files/upload with wrong user's post_id +7. **Expected:** 403 Forbidden response + +### TC-09: Edge Cases +1. Create a post with no attachments → save succeeds +2. Create a post with empty body → save succeeds (title-only post) +3. Upload a file with special characters in filename (e.g., "my file (v2).zip") → filename sanitized, upload succeeds +4. Visit public profile for creator with zero posts → Posts section hidden (no empty state) +5. Attempt GET /api/v1/posts/nonexistent-uuid → 404 response diff --git a/.gsd/milestones/M023/slices/S01/tasks/T04-VERIFY.json b/.gsd/milestones/M023/slices/S01/tasks/T04-VERIFY.json new file mode 100644 index 0000000..8b0dade --- /dev/null +++ b/.gsd/milestones/M023/slices/S01/tasks/T04-VERIFY.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "taskId": "T04", + "unitId": "M023/S01/T04", + "timestamp": 1775294250736, + "passed": true, + "discoverySource": "task-plan", + "checks": [ + { + "command": "cd frontend", + "exitCode": 0, + "durationMs": 6, + "verdict": "pass" + } + ] +} diff --git a/.gsd/milestones/M023/slices/S02/S02-PLAN.md b/.gsd/milestones/M023/slices/S02/S02-PLAN.md index 547e322..9a714ff 100644 --- a/.gsd/milestones/M023/slices/S02/S02-PLAN.md +++ b/.gsd/milestones/M023/slices/S02/S02-PLAN.md @@ -1,6 +1,75 @@ # S02: [A] Chat Widget ↔ Chat Engine Wiring (INT-1) -**Goal:** Wire chat widget UI to chat engine via creator-scoped router with personality system. INT-1 complete. +**Goal:** Chat widget on creator profile sends personality_weight (0.0–1.0) to the chat engine. Backend modulates the system prompt: 0.0 = encyclopedic, >0 = creator voice injected from personality_profile JSONB. Slider UI visible in chat panel header. **Demo:** After this: Chat widget on creator profile wired to chat engine. Personality slider adjusts response style. Citations link to sources. ## Tasks +- [x] **T01: Added personality_weight (0.0–1.0) to chat API; modulates system prompt with creator voice profile and scales LLM temperature** — Thread personality_weight through the chat API and modulate the system prompt based on creator personality profile. + +## Failure Modes + +| Dependency | On error | On timeout | On malformed response | +|---|---|---|---| +| Creator DB query | Log warning, fall back to encyclopedic prompt | Same — DB timeout treated as missing profile | N/A — query returns model or None | + +## Negative Tests + +- **Malformed inputs**: personality_weight outside 0.0–1.0 → 422, personality_weight as string → 422 +- **Error paths**: creator name doesn't match any DB record → encyclopedic fallback, personality_profile is null → encyclopedic fallback +- **Boundary conditions**: weight exactly 0.0 → no profile query needed, weight exactly 1.0 → full personality injection + +## Steps + +1. In `backend/routers/chat.py`, add `personality_weight: float = Field(default=0.0, ge=0.0, le=1.0)` to `ChatRequest`. Pass it to `service.stream_response()`. +2. In `backend/chat_service.py`, add `personality_weight: float = 0.0` param to `stream_response()`. When weight > 0 and creator is not None: + - Import `Creator` from models, `select` from sqlalchemy + - Query `select(Creator).where(Creator.name == creator)` using the existing `db` session + - If creator found and `personality_profile` is not None, build a personality injection block from the profile dict (extract signature_phrases, tone descriptors, teaching_style, energy, formality from the nested vocabulary/tone/style_markers structure) + - Append the personality block to the system prompt: 'Respond in {creator}'s voice. {profile_summary}. Use their signature phrases: {phrases}. Match their {formality} {energy} tone.' + - If creator not found or profile is null, log at DEBUG and use the standard encyclopedic prompt +3. Scale LLM temperature with weight: `temperature = 0.3 + (personality_weight * 0.2)` (range 0.3–0.5). +4. Extend the `logger.info('chat_request ...')` line to include `weight=body.personality_weight`. +5. Add tests to `backend/tests/test_chat.py`: + - Test personality_weight is accepted and forwarded (mock verifies stream_response called with weight) + - Test system prompt includes personality context when weight > 0 and profile exists (mock Creator query, capture messages sent to OpenAI) + - Test encyclopedic fallback when weight > 0 but personality_profile is null + - Test 422 for personality_weight outside [0.0, 1.0] +6. Run `cd backend && python -m pytest tests/test_chat.py -v` — all tests pass. + +## Must-Haves + +- [ ] ChatRequest schema has personality_weight with ge=0.0, le=1.0, default=0.0 +- [ ] stream_response queries Creator.personality_profile when weight > 0 +- [ ] Null profile or missing creator falls back to encyclopedic prompt silently +- [ ] Temperature scales with weight (0.3 at 0.0, up to 0.5 at 1.0) +- [ ] All existing tests still pass +- [ ] New tests for weight forwarding, prompt injection, null fallback, validation + - Estimate: 1h + - Files: backend/routers/chat.py, backend/chat_service.py, backend/tests/test_chat.py + - Verify: cd backend && python -m pytest tests/test_chat.py -v +- [ ] **T02: Frontend — personality slider UI + API wiring in ChatWidget** — Add a personality weight slider to ChatWidget and thread it through the streamChat() API call. + +## Steps + +1. In `frontend/src/api/chat.ts`, add `personalityWeight?: number` parameter to `streamChat()` function signature (after `conversationId`). Include `personality_weight: personalityWeight ?? 0` in the JSON body. +2. In `frontend/src/components/ChatWidget.tsx`: + - Add `const [personalityWeight, setPersonalityWeight] = useState(0);` state + - In the panel header (the `